custom-adal 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +6 -0
  3. data/.rubocop.yml +7 -0
  4. data/.travis.yml +7 -0
  5. data/Gemfile +25 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +106 -0
  8. data/Rakefile +39 -0
  9. data/adal.gemspec +52 -0
  10. data/contributing.md +127 -0
  11. data/lib/adal/authentication_context.rb +202 -0
  12. data/lib/adal/authentication_parameters.rb +126 -0
  13. data/lib/adal/authority.rb +165 -0
  14. data/lib/adal/cache_driver.rb +171 -0
  15. data/lib/adal/cached_token_response.rb +190 -0
  16. data/lib/adal/client_assertion.rb +63 -0
  17. data/lib/adal/client_assertion_certificate.rb +89 -0
  18. data/lib/adal/client_credential.rb +46 -0
  19. data/lib/adal/core_ext/hash.rb +34 -0
  20. data/lib/adal/core_ext.rb +26 -0
  21. data/lib/adal/jwt_parameters.rb +39 -0
  22. data/lib/adal/logger.rb +90 -0
  23. data/lib/adal/logging.rb +98 -0
  24. data/lib/adal/memory_cache.rb +95 -0
  25. data/lib/adal/mex_request.rb +52 -0
  26. data/lib/adal/mex_response.rb +141 -0
  27. data/lib/adal/noop_cache.rb +38 -0
  28. data/lib/adal/oauth_request.rb +76 -0
  29. data/lib/adal/request_parameters.rb +48 -0
  30. data/lib/adal/self_signed_jwt_factory.rb +96 -0
  31. data/lib/adal/templates/rst.13.xml.erb +35 -0
  32. data/lib/adal/templates/rst.2005.xml.erb +32 -0
  33. data/lib/adal/token_request.rb +231 -0
  34. data/lib/adal/token_response.rb +144 -0
  35. data/lib/adal/user_assertion.rb +57 -0
  36. data/lib/adal/user_credential.rb +152 -0
  37. data/lib/adal/user_identifier.rb +83 -0
  38. data/lib/adal/user_information.rb +49 -0
  39. data/lib/adal/util.rb +49 -0
  40. data/lib/adal/version.rb +36 -0
  41. data/lib/adal/wstrust_request.rb +100 -0
  42. data/lib/adal/wstrust_response.rb +168 -0
  43. data/lib/adal/xml_namespaces.rb +64 -0
  44. data/lib/adal.rb +24 -0
  45. data/samples/authorization_code_example/README.md +10 -0
  46. data/samples/authorization_code_example/web_app.rb +139 -0
  47. data/samples/client_assertion_certificate_example/README.md +42 -0
  48. data/samples/client_assertion_certificate_example/app.rb +55 -0
  49. data/samples/on_behalf_of_example/README.md +35 -0
  50. data/samples/on_behalf_of_example/native_app.rb +52 -0
  51. data/samples/on_behalf_of_example/web_api.rb +71 -0
  52. data/samples/user_credentials_example/README.md +7 -0
  53. data/samples/user_credentials_example/app.rb +52 -0
  54. data/spec/adal/authentication_context_spec.rb +186 -0
  55. data/spec/adal/authentication_parameters_spec.rb +107 -0
  56. data/spec/adal/authority_spec.rb +122 -0
  57. data/spec/adal/cache_driver_spec.rb +191 -0
  58. data/spec/adal/cached_token_response_spec.rb +148 -0
  59. data/spec/adal/client_assertion_certificate_spec.rb +113 -0
  60. data/spec/adal/client_assertion_spec.rb +38 -0
  61. data/spec/adal/core_ext/hash_spec.rb +47 -0
  62. data/spec/adal/logging_spec.rb +48 -0
  63. data/spec/adal/memory_cache_spec.rb +107 -0
  64. data/spec/adal/mex_request_spec.rb +57 -0
  65. data/spec/adal/mex_response_spec.rb +143 -0
  66. data/spec/adal/self_signed_jwt_factory_spec.rb +63 -0
  67. data/spec/adal/token_request_spec.rb +150 -0
  68. data/spec/adal/token_response_spec.rb +102 -0
  69. data/spec/adal/user_credential_spec.rb +125 -0
  70. data/spec/adal/user_identifier_spec.rb +115 -0
  71. data/spec/adal/wstrust_request_spec.rb +51 -0
  72. data/spec/adal/wstrust_response_spec.rb +152 -0
  73. data/spec/fixtures/mex/insecureaddress.xml +924 -0
  74. data/spec/fixtures/mex/invalid_namespaces.xml +916 -0
  75. data/spec/fixtures/mex/malformed.xml +914 -0
  76. data/spec/fixtures/mex/microsoft.xml +916 -0
  77. data/spec/fixtures/mex/multiple_endpoints.xml +922 -0
  78. data/spec/fixtures/mex/no_matching_bindings.xml +916 -0
  79. data/spec/fixtures/mex/no_username_token_policies.xml +914 -0
  80. data/spec/fixtures/mex/no_wstrust_endpoints.xml +838 -0
  81. data/spec/fixtures/mex/only_13.xml +842 -0
  82. data/spec/fixtures/mex/only_2005.xml +842 -0
  83. data/spec/fixtures/oauth/error.json +1 -0
  84. data/spec/fixtures/oauth/success.json +1 -0
  85. data/spec/fixtures/oauth/success_with_id_token.json +1 -0
  86. data/spec/fixtures/wstrust/error.xml +24 -0
  87. data/spec/fixtures/wstrust/invalid_namespaces.xml +136 -0
  88. data/spec/fixtures/wstrust/missing_security_tokens.xml +90 -0
  89. data/spec/fixtures/wstrust/success.xml +136 -0
  90. data/spec/fixtures/wstrust/token.xml +1 -0
  91. data/spec/fixtures/wstrust/too_many_security_tokens.xml +219 -0
  92. data/spec/fixtures/wstrust/unrecognized_token_type.xml +136 -0
  93. data/spec/fixtures/wstrust/wstrust.13.xml +1 -0
  94. data/spec/fixtures/wstrust/wstrust.2005.xml +89 -0
  95. data/spec/spec_helper.rb +53 -0
  96. data/spec/support/fake_data.rb +40 -0
  97. data/spec/support/fake_token_endpoint.rb +108 -0
  98. metadata +264 -0
@@ -0,0 +1,126 @@
1
+ #-------------------------------------------------------------------------------
2
+ # Copyright (c) 2015 Micorosft Corporation
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #-------------------------------------------------------------------------------
22
+
23
+ require_relative './logging'
24
+ require_relative './util'
25
+
26
+ require 'net/http'
27
+ require 'uri'
28
+
29
+ module ADAL
30
+ # Authentication parameters from an unauthorized 401 response from a resource
31
+ # server that can be used to create an AuthenticationContext.
32
+ class AuthenticationParameters
33
+ extend Logging
34
+ include Util
35
+
36
+ AUTHENTICATE_HEADER = 'www-authenticate'
37
+ AUTHORITY_KEY = 'authorization_uri'
38
+ RESOURCE_KEY = 'resource'
39
+
40
+ BEARER_CHALLENGE_VALIDATION = /^\s*Bearer\s+([^,\s="]+?)="?([^"]*?)"?\s*
41
+ (,\s*([^,\s="]+?)="([^:]*?)"\s*)*$/x
42
+ private_constant :BEARER_CHALLENGE_VALIDATION
43
+ FIRST_KEY_VALUE = /^\s*Bearer\s+([^, \s="]+?)="([^"]*?)"\s*/
44
+ private_constant :FIRST_KEY_VALUE
45
+ OTHER_KEY_VALUE = /(?:,\s*([^,\s="]+?)="([^"]*?)"\s*)/
46
+ private_constant :OTHER_KEY_VALUE
47
+
48
+ attr_reader :authority_uri
49
+ attr_reader :resource
50
+
51
+ ##
52
+ # Creates authentication parameters from the address of the resource. The
53
+ # resource server must respond with 401 unauthorized response with a
54
+ # www-authenticate header containing the authentication parameters.
55
+ #
56
+ # @param URI resource_url
57
+ # The address of the desired resource.
58
+ # @return AuthenticationParameters
59
+ def self.create_from_resource_url(resource_url)
60
+ logger.verbose('Attempting to retrieve authentication parameters from ' \
61
+ "#{resource_url}.")
62
+ response = Net::HTTP.post_form(URI.parse(resource_url.to_s), {})
63
+ unless response.key? AUTHENTICATE_HEADER
64
+ fail ArgumentError, 'The specified resource uri does not support ' \
65
+ 'OAuth challenges.'
66
+ end
67
+ create_from_authenticate_header(response[AUTHENTICATE_HEADER])
68
+ end
69
+
70
+ ##
71
+ # Creates an AuthenticationParameters object from a www-authenticate
72
+ # response header.
73
+ #
74
+ # @param String challenge
75
+ # The raw www-authenticate header.
76
+ # @return AuthenticationParameters
77
+ def self.create_from_authenticate_header(challenge)
78
+ params = parse_challenge(challenge)
79
+ if params.nil? || !params.key?(AUTHORITY_KEY)
80
+ logger.warn('Unable to create AuthenticationParameters from header ' \
81
+ "#{challenge}.")
82
+ return
83
+ end
84
+ logger.verbose("Authentication header #{challenge} was successfully " \
85
+ 'parsed as an OAuth challenge into a parameters hash.')
86
+ AuthenticationParameters.new(
87
+ params[AUTHORITY_KEY], params[RESOURCE_KEY])
88
+ end
89
+
90
+ ##
91
+ # Parses a challenge from the www-authenticate header into a hash of
92
+ # parameters.
93
+ #
94
+ # @param String challenge
95
+ # @return Hash
96
+ def self.parse_challenge(challenge)
97
+ if challenge !~ BEARER_CHALLENGE_VALIDATION
98
+ logger.warn("#{challenge} is not parseable as an RFC6750 OAuth2 " \
99
+ 'challenge.')
100
+ return
101
+ end
102
+ Hash[challenge.scan(FIRST_KEY_VALUE) + challenge.scan(OTHER_KEY_VALUE)]
103
+ end
104
+ private_class_method :parse_challenge
105
+
106
+ ##
107
+ # Constructs a new AuthenticationParameters.
108
+ #
109
+ # @param String|URI authority_uri
110
+ # The uri of the authority server, including both host and tenant.
111
+ # @param String
112
+ def initialize(authority_uri, resource = nil)
113
+ fail_if_arguments_nil(authority_uri)
114
+ @authority_uri = URI.parse(authority_uri.to_s)
115
+ @resource = resource
116
+ end
117
+
118
+ ##
119
+ # Creates an AuthenticationContext based on the parameters.
120
+ #
121
+ # @return AuthenticationContext
122
+ def create_context
123
+ AuthenticationContext.new(@authority_uri.host, @authority_uri.path[1..-1])
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,165 @@
1
+ #-------------------------------------------------------------------------------
2
+ # Copyright (c) 2015 Micorosft Corporation
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #-------------------------------------------------------------------------------
22
+
23
+ require_relative './logging'
24
+
25
+ require 'json'
26
+ require 'net/http'
27
+ require 'uri'
28
+ require 'uri_template'
29
+
30
+ module ADAL
31
+ # An authentication and token server with the ability to self validate.
32
+ class Authority
33
+ include Logging
34
+
35
+ AUTHORIZE_PATH = '/oauth2/authorize'
36
+ COMMON_TENANT = 'common'
37
+ DISCOVERY_TEMPLATE = URITemplate.new('https://{host}/common/discovery/' \
38
+ 'instance?authorization_endpoint={endpoint}&api-version=1.0')
39
+ TENANT_DISCOVERY_ENDPOINT_KEY = 'tenant_discovery_endpoint'
40
+ TOKEN_PATH = '/oauth2/token'
41
+ WELL_KNOWN_AUTHORITY_HOSTS = [
42
+ 'login.windows.net',
43
+ 'login.microsoftonline.com',
44
+ 'login.chinacloudapi.cn',
45
+ 'login.cloudgovapi.us'
46
+ ]
47
+ WORLD_WIDE_AUTHORITY = 'login.microsoftonline.com'
48
+
49
+ attr_reader :host
50
+ attr_reader :tenant
51
+
52
+ ##
53
+ # Creates a new Authority.
54
+ #
55
+ # @param [String] host
56
+ # The host name of the authority server.
57
+ # @param [String] tenant
58
+ # The name of the tenant for the Authority to access.
59
+ # @option [Boolean] validate_authority (false)
60
+ # The setting that controls whether the Authority instance will check that
61
+ # it matches a set of know authorities or can dynamically retrieve an
62
+ # identifying response.
63
+ def initialize(host = WORLD_WIDE_AUTHORITY,
64
+ tenant = COMMON_TENANT,
65
+ validate_authority = false)
66
+ @host = host
67
+ @tenant = tenant
68
+ @validated = !validate_authority
69
+ end
70
+
71
+ public
72
+
73
+ ##
74
+ # URI that can be used to acquire authorization codes.
75
+ #
76
+ # @optional Hash params
77
+ # Query parameters that will added to the endpoint.
78
+ # @return [URI]
79
+ def authorize_endpoint(params = nil)
80
+ params = params.select { |_, v| !v.nil? } if params.respond_to? :select
81
+ if params.nil? || params.empty?
82
+ URI::HTTPS.build(host: @host, path: '/' + @tenant + AUTHORIZE_PATH)
83
+ else
84
+ URI::HTTPS.build(host: @host,
85
+ path: '/' + @tenant + AUTHORIZE_PATH,
86
+ query: URI.encode_www_form(params))
87
+ end
88
+ end
89
+
90
+ ##
91
+ # URI that can be used to acquire tokens.
92
+ #
93
+ # @return [URI]
94
+ def token_endpoint
95
+ URI::HTTPS.build(host: @host, path: '/' + @tenant + TOKEN_PATH)
96
+ end
97
+
98
+ ##
99
+ # Checks if the authority matches a set list of known authorities or if it
100
+ # can be resolved by the discovery endpoint.
101
+ #
102
+ # @return [Boolean]
103
+ # True if the Authority was successfully validated.
104
+ def validate
105
+ @validated = validated_statically? unless validated?
106
+ @validated = validated_dynamically? unless validated?
107
+ @validated
108
+ end
109
+
110
+ # @return [Boolean]
111
+ def validated?
112
+ @validated
113
+ end
114
+
115
+ private
116
+
117
+ ##
118
+ # Creates an instance discovery endpoint url for authority that this object
119
+ # represents.
120
+ #
121
+ # @return [URI]
122
+ def discovery_uri(host = WORLD_WIDE_AUTHORITY)
123
+ URI(DISCOVERY_TEMPLATE.expand(host: host, endpoint: authorize_endpoint))
124
+ end
125
+
126
+ ##
127
+ # Performs instance discovery via a network call to well known authorities.
128
+ #
129
+ # @return [String]
130
+ # The tenant discovery endpoint, if found. Otherwise nil.
131
+ def validated_dynamically?
132
+ logger.verbose("Attempting instance discovery at: #{discovery_uri}.")
133
+ http_response = Net::HTTP.get(discovery_uri)
134
+ if http_response.nil?
135
+ logger.error('Dynamic validation received no response from endpoint.')
136
+ return false
137
+ end
138
+ parse_dynamic_validation(JSON.parse(http_response))
139
+ end
140
+
141
+ # @return [Boolean]
142
+ def validated_statically?
143
+ logger.verbose('Performing static instance discovery.')
144
+ found_it = WELL_KNOWN_AUTHORITY_HOSTS.include? @host
145
+ if found_it
146
+ logger.verbose('Authority validated via static instance discovery.')
147
+ end
148
+ found_it
149
+ end
150
+
151
+ private
152
+
153
+ # @param Hash
154
+ # @return Boolean
155
+ def parse_dynamic_validation(response)
156
+ unless response.key? TENANT_DISCOVERY_ENDPOINT_KEY
157
+ logger.error('Received unexpected response from instance discovery ' \
158
+ "endpoint: #{response}. Unable to validate dynamically.")
159
+ return false
160
+ end
161
+ logger.verbose('Authority validated via dynamic instance discovery.')
162
+ response[TENANT_DISCOVERY_ENDPOINT_KEY]
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,171 @@
1
+ #-------------------------------------------------------------------------------
2
+ # Copyright (c) 2015 Micorosft Corporation
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #-------------------------------------------------------------------------------
22
+
23
+ require_relative './core_ext'
24
+ require_relative './logging'
25
+ require_relative './request_parameters'
26
+ require_relative './util'
27
+
28
+ using ADAL::CoreExt
29
+
30
+ module ADAL
31
+ # Performs logical operations on the TokenCache in the context of one token
32
+ # request.
33
+ class CacheDriver
34
+ include Logging
35
+ include RequestParameters
36
+
37
+ FIELDS = { user_info: USER_INFO,
38
+ username: USERNAME,
39
+ resource: RESOURCE }
40
+
41
+ ##
42
+ # Constructs a CacheDriver to interact with a token cache.
43
+ #
44
+ # @param String authority
45
+ # The URL of the authority endpoint.
46
+ # @param ClientAssertion|ClientCredential|etc client
47
+ # The credentials representing the calling application. We need this
48
+ # instead of just the client id so that the tokens can be refreshed if
49
+ # necessary.
50
+ # @param TokenCache token_cache
51
+ # The cache implementation to store tokens.
52
+ # @optional Fixnum expiration_buffer_sec
53
+ # The number of seconds to use as a leeway when dealing with cache expiry.
54
+ def initialize(
55
+ authority, client, token_cache = NoopCache.new, expiration_buffer_sec = 0)
56
+ @authority = authority
57
+ @client = client
58
+ @expiration_buffer_sec = expiration_buffer_sec
59
+ @token_cache = token_cache
60
+ end
61
+
62
+ ##
63
+ # Checks if a TokenResponse is successful and if so adds it to the token
64
+ # cache for future retrieval.
65
+ #
66
+ # @param SuccessResponse token_response
67
+ # The successful token response to be cached. If it is not successful, it
68
+ # fails silently.
69
+ def add(token_response)
70
+ return unless token_response.instance_of? SuccessResponse
71
+ logger.verbose('Adding successful TokenResponse to cache.')
72
+ entry = CachedTokenResponse.new(@client, @authority, token_response)
73
+ update_refresh_tokens(entry) if entry.mrrt?
74
+ @token_cache.add(entry)
75
+ end
76
+
77
+ ##
78
+ # Searches the cache for a token matching a specific query of fields.
79
+ #
80
+ # @param Hash query
81
+ # The fields to match against.
82
+ # @return TokenResponse
83
+ def find(query = {})
84
+ query = query.map { |k, v| [FIELDS[k], v] if FIELDS[k] }.compact.to_h
85
+ resource = query.delete(RESOURCE)
86
+ matches = validate(
87
+ find_all_cached_entries(
88
+ query.reverse_merge(
89
+ authority: @authority, client_id: @client.client_id))
90
+ )
91
+ resource_specific(matches, resource) || refresh_mrrt(matches, resource)
92
+ end
93
+
94
+ private
95
+
96
+ ##
97
+ # All cache entries that match a query. This matches keys in values against
98
+ # a hash to method calls on an object.
99
+ #
100
+ # @param Hash query
101
+ # The fields to be matched and the values to match them to.
102
+ # @return Array<CachedTokenResponse>
103
+ def find_all_cached_entries(query)
104
+ logger.verbose("Searching cache for tokens by keys: #{query.keys}.")
105
+ @token_cache.find do |entry|
106
+ query.map do |k, v|
107
+ (entry.respond_to? k.to_sym) && (v == entry.send(k.to_sym))
108
+ end.reduce(:&)
109
+ end
110
+ end
111
+
112
+ ##
113
+ # Attempts to obtain an access token for a resource with refresh tokens from
114
+ # a list of MRRTs.
115
+ #
116
+ # @param Array[CachedTokenResponse]
117
+ # @return SuccessResponse|nil
118
+ def refresh_mrrt(responses, resource)
119
+ logger.verbose("Attempting to obtain access token for #{resource} by " \
120
+ "refreshing 1 of #{responses.count(&:mrrt?)} matching " \
121
+ 'MRRTs.')
122
+ responses.each do |response|
123
+ if response.mrrt?
124
+ refresh_response = response.refresh(resource)
125
+ return refresh_response if add(refresh_response)
126
+ end
127
+ end
128
+ nil
129
+ end
130
+
131
+ ##
132
+ # Searches a list of CachedTokenResponses for one that matches the resource.
133
+ #
134
+ # @param Array[CachedTokenResponse]
135
+ # @return SuccessResponse|nil
136
+ def resource_specific(responses, resource)
137
+ logger.verbose("Looking through #{responses.size} matching cache " \
138
+ "entries for resource #{resource}.")
139
+ responses.select { |response| response.resource == resource }
140
+ .map(&:token_response).first
141
+ end
142
+
143
+ ##
144
+ # Updates the refresh tokens of all tokens in the cache that match a given
145
+ # MRRT.
146
+ #
147
+ # @param CachedTokenResponse mrrt
148
+ # A new MRRT containing a refresh token to update other matching cache
149
+ # entries with.
150
+ def update_refresh_tokens(mrrt)
151
+ fail ArgumentError, 'Token must contain an MRRT.' unless mrrt.mrrt?
152
+ @token_cache.find.each do |entry|
153
+ entry.refresh_token = mrrt.refresh_token if mrrt.can_refresh?(entry)
154
+ end
155
+ end
156
+
157
+ ##
158
+ # Checks if an array of current cache entries are still valid, attempts to
159
+ # refresh those that have expired and discards those that cannot be.
160
+ #
161
+ # @param Array[CachedTokenResponse] entries
162
+ # The tokens to validate.
163
+ # @return Array[CachedTokenResponse]
164
+ def validate(entries)
165
+ logger.verbose("Validating #{entries.size} possible cache matches.")
166
+ valid_entries = entries.group_by(&:validate)
167
+ @token_cache.remove(valid_entries[false] || [])
168
+ valid_entries[true] || []
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,190 @@
1
+ #-------------------------------------------------------------------------------
2
+ # Copyright (c) 2015 Micorosft Corporation
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #-------------------------------------------------------------------------------
22
+
23
+ module ADAL
24
+ # Proxy object for a token response with metadata.
25
+ class CachedTokenResponse
26
+ attr_reader :authority
27
+ attr_reader :client_id
28
+ attr_reader :token_response
29
+
30
+ ##
31
+ # Constructs a new CachedTokenResponse.
32
+ #
33
+ # @param ClientCredential|ClientAssertion|ClientAssertionCertificate
34
+ # The credentials of the calling client application.
35
+ # @param Authority authority
36
+ # The ADAL::Authority object that the response was retrieved from.
37
+ # @param SuccessResponse token_response
38
+ # The token response to be cached.
39
+ def initialize(client, authority, token_response)
40
+ unless token_response.instance_of? SuccessResponse
41
+ fail ArgumentError, 'Only SuccessResponses can be cached.'
42
+ end
43
+ @authority = authority
44
+ if client.respond_to? :client_id
45
+ @client = client
46
+ @client_id = client.client_id
47
+ else
48
+ @client = ClientCredential.new(client)
49
+ @client_id = client
50
+ end
51
+ @token_response = token_response
52
+ end
53
+
54
+ ##
55
+ # Converts the fields in this object and its proxied SuccessResponse into
56
+ # a JSON string.
57
+ #
58
+ # @param JSON::Ext::Generator::State
59
+ # We don't care about the state, but JSON::unparse requires this.
60
+ # @return String
61
+ def to_json(_ = nil)
62
+ JSON.unparse(authority: [authority.host, authority.tenant],
63
+ client_id: client_id,
64
+ token_response: token_response)
65
+ end
66
+
67
+ ##
68
+ # Reconstructs an object from JSON that was serialized with
69
+ # CachedTokenResponse#to_json.
70
+ #
71
+ # @param JSON raw_json
72
+ # @return CachedTokenResponse
73
+ def self.from_json(json)
74
+ json = JSON.parse(json) if json.instance_of? String
75
+ CachedTokenResponse.new(json['client_id'],
76
+ Authority.new(*json['authority']),
77
+ SuccessResponse.new(json['token_response']))
78
+ end
79
+
80
+ ##
81
+ # Determines if self can be used to refresh other.
82
+ #
83
+ # @param CachedTokenResponse other
84
+ # @return Boolean
85
+ def can_refresh?(other)
86
+ mrrt? && (authority == other.authority) &&
87
+ (user_info == other.user_info) && (client_id == other.client_id)
88
+ end
89
+
90
+ ##
91
+ # If the access token is within the expiration buffer of expiring, an
92
+ # attempt will be made to retrieve a new token with the refresh token.
93
+ #
94
+ # @param Fixnum expiration_buffer_sec
95
+ # The number of seconds to use as leeway in determining if the token is
96
+ # expired. A positive buffer will refresh the token early while a negative
97
+ # buffer will refresh it late. Used to counter clock skew and network
98
+ # latency.
99
+ # @return Boolean
100
+ # True if the token is still valid (even if it was refreshed). False if
101
+ # the token is expired an unable to be refreshed.
102
+ def validate(expiration_buffer_sec = 0)
103
+ return true if (Time.now + expiration_buffer_sec).to_i < expires_on
104
+ unless refresh_token
105
+ logger.verbose('Cached token is almost expired but no refresh token ' \
106
+ 'is available.')
107
+ return false
108
+ end
109
+ logger.verbose('Cached token is almost expired, attempting to refresh ' \
110
+ ' with refresh token.')
111
+ refresh_response = refresh
112
+ if refresh_response.instance_of? SuccessResponse
113
+ logger.verbose('Successfully refreshed token in cache.')
114
+ @token_response = refresh_response
115
+ true
116
+ else
117
+ logger.warn('Failed to refresh token in cache with refresh token.')
118
+ false
119
+ end
120
+ end
121
+
122
+ ##
123
+ # Attempts to refresh the access token for a given resource. Note that you
124
+ # can call this method with a different resource even if the token is not
125
+ # an MRRT, but it will fail
126
+ #
127
+ # @param String resource
128
+ # The resource that the new access token is beign requested for. Defaults
129
+ # to using the same resource as the original token.
130
+ # @return TokenResponse
131
+ def refresh(new_resource = resource)
132
+ token_response = TokenRequest
133
+ .new(authority, @client)
134
+ .get_with_refresh_token(refresh_token, new_resource)
135
+ if token_response.instance_of? SuccessResponse
136
+ token_response.parse_id_token(id_token)
137
+ end
138
+ token_response
139
+ end
140
+
141
+ ##
142
+ # Changes the refresh token of the underlying token response.
143
+ #
144
+ # @param String token
145
+ def refresh_token=(token)
146
+ token_response.instance_variable_set(:@refresh_token, token)
147
+ logger.verbose("Updated the refresh token for #{token_response}.")
148
+ end
149
+
150
+ ##
151
+ # Is the token a Multi Resource Refresh Token?
152
+ #
153
+ # @return Boolean
154
+ def mrrt?
155
+ token_response.refresh_token && token_response.resource
156
+ end
157
+
158
+ ## Since the token cache may be implemented by the user of this library,
159
+ ## all means of checking equality must be consistent.
160
+
161
+ def ==(other)
162
+ [:authority, :client_id, :token_response].all? do |field|
163
+ (other.respond_to? field) && (send(field) == other.send(field))
164
+ end
165
+ end
166
+
167
+ def eql?(other)
168
+ self == other
169
+ end
170
+
171
+ def hash
172
+ [authority, client_id, token_response].hash
173
+ end
174
+
175
+ private
176
+
177
+ # CachedTokenResponse is just a proxy for TokenResponse.
178
+ def method_missing(method, *args, &block)
179
+ if token_response.respond_to?(method)
180
+ token_response.send(method, *args, &block)
181
+ else
182
+ super(method)
183
+ end
184
+ end
185
+
186
+ def respond_to_missing?(method, include_private = false)
187
+ token_response.respond_to?(method, include_private) || super
188
+ end
189
+ end
190
+ end