cf-uaa-lib 1.3.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,124 @@
1
+ #--
2
+ # Cloud Foundry 2012.02.03 Beta
3
+ # Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
4
+ #
5
+ # This product is licensed to you under the Apache License, Version 2.0 (the "License").
6
+ # You may not use this product except in compliance with the License.
7
+ #
8
+ # This product includes a number of subcomponents with
9
+ # separate copyright notices and license terms. Your use of these
10
+ # subcomponents is subject to the terms and conditions of the
11
+ # subcomponent's license, as noted in the LICENSE file.
12
+ #++
13
+
14
+ require "openssl"
15
+ require "uaa/util"
16
+
17
+ module CF::UAA
18
+
19
+ # This class is for OAuth Resource Servers.
20
+ # Resource Servers get tokens and need to validate and decode them,
21
+ # but they do not obtain them from the Authorization Server. This
22
+ # class it for Resource Servers which accept Bearer JWT tokens. An
23
+ # instance of this class can be used to decode and verify the contents
24
+ # of a bearer token. The Authorization Server will have signed the
25
+ # token and shared a secret key so that the Resource Server can verify
26
+ # the signature. The Authorization Server may also have given the
27
+ # Resource Server an id, in which case it must verify a matching value
28
+ # is in the access token.
29
+ class TokenCoder
30
+
31
+ def self.init_digest(algo) # :nodoc:
32
+ OpenSSL::Digest::Digest.new(algo.sub('HS', 'sha').sub('RS', 'sha'))
33
+ end
34
+
35
+ # takes a token_body (the middle section of the jwt) and returns a signed token
36
+ def self.encode(token_body, skey, pkey = nil, algo = 'HS256')
37
+ segments = [Util.json_encode64("typ" => "JWT", "alg" => algo)]
38
+ segments << Util.json_encode64(token_body)
39
+ if ["HS256", "HS384", "HS512"].include?(algo)
40
+ sig = OpenSSL::HMAC.digest(init_digest(algo), skey, segments.join('.'))
41
+ elsif ["RS256", "RS384", "RS512"].include?(algo)
42
+ sig = pkey.sign(init_digest(algo), segments.join('.'))
43
+ elsif algo == "none"
44
+ sig = ""
45
+ else
46
+ raise ArgumentError, "unsupported signing method"
47
+ end
48
+ segments << Util.encode64(sig)
49
+ segments.join('.')
50
+ end
51
+
52
+ def self.decode(token, skey = nil, pkey = nil, verify = true)
53
+ pkey = OpenSSL::PKey::RSA.new(pkey) unless pkey.nil? || pkey.is_a?(OpenSSL::PKey::PKey)
54
+ segments = token.split('.')
55
+ raise DecodeError, "Not enough or too many segments" unless [2,3].include? segments.length
56
+ header_segment, payload_segment, crypto_segment = segments
57
+ signing_input = [header_segment, payload_segment].join('.')
58
+ header = Util.json_decode64(header_segment)
59
+ payload = Util.json_decode64(payload_segment)
60
+ return payload if !verify || (algo = header["alg"]) == "none"
61
+ signature = Util.decode64(crypto_segment)
62
+ if ["HS256", "HS384", "HS512"].include?(algo)
63
+ raise DecodeError, "Signature verification failed" unless
64
+ signature == OpenSSL::HMAC.digest(init_digest(algo), skey, signing_input)
65
+ elsif ["RS256", "RS384", "RS512"].include?(algo)
66
+ raise DecodeError, "Signature verification failed" unless
67
+ pkey.verify(init_digest(algo), signature, signing_input)
68
+ else
69
+ raise DecodeError, "Algorithm not supported"
70
+ end
71
+ payload
72
+ end
73
+
74
+ # Create a new token en/decoder for a service that is associated with
75
+ # the the audience_ids, the symmetrical token validation key, and the
76
+ # public and/or private keys. Parameters:
77
+ # * audience_ids - an array or space separated strings and should
78
+ # indicate values which indicate the token is intended for this service
79
+ # instance. It will be compared with tokens as they are decoded to
80
+ # ensure that the token was intended for this audience.
81
+ # * skey - is used to sign and validate tokens using symetrical key
82
+ # algoruthms
83
+ # * pkey - pkey may be a string or File which includes public and
84
+ # optionally private key data in PEM or DER formats. The private key
85
+ # is used to sign tokens and the public key is used to validate tokens.
86
+ def initialize(audience_ids, skey, pkey = nil)
87
+ @audience_ids, @skey, @pkey = Util.arglist(audience_ids), skey, pkey
88
+ @pkey = OpenSSL::PKey::RSA.new(pkey) unless pkey.nil? || pkey.is_a?(OpenSSL::PKey::PKey)
89
+ end
90
+
91
+ # Encode a JWT token. Takes a hash of values to use as the token body.
92
+ # Returns a signed token in JWT format (header, body, signature).
93
+ # Algorithm may be HS256, HS384, HS512, RS256, RS384, RS512, or none --
94
+ # assuming the TokenCoder instance is configured with the appropriate
95
+ # key -- i.e. pkey must include a private key for the RS algorithms.
96
+ def encode(token_body = {}, algorithm = 'HS256')
97
+ token_body['aud'] = @audience_ids unless token_body['aud']
98
+ token_body['exp'] = Time.now.to_i + 7 * 24 * 60 * 60 unless token_body['exp']
99
+ self.class.encode(token_body, @skey, @pkey, algorithm)
100
+ end
101
+
102
+ # Returns hash of values decoded from the token contents. If the
103
+ # token contains resource ids and they do not contain the id of the
104
+ # caller there will be an AuthError. If the token has expired there
105
+ # will also be an AuthError.
106
+ def decode(auth_header)
107
+ unless auth_header && (tkn = auth_header.split).length == 2 && tkn[0] =~ /^bearer$/i
108
+ raise DecodeError, "invalid authentication header: #{auth_header}"
109
+ end
110
+ reply = self.class.decode(tkn[1], @skey, @pkey)
111
+ auds = Util.arglist(reply["aud"])
112
+ if @audience_ids && (!auds || (auds & @audience_ids).empty?)
113
+ raise AuthError, "invalid audience: #{auds.join(' ')}"
114
+ end
115
+ exp = reply["exp"]
116
+ unless exp.is_a?(Integer) && exp > Time.now.to_i
117
+ raise AuthError, "token expired"
118
+ end
119
+ reply
120
+ end
121
+
122
+ end
123
+
124
+ end
@@ -0,0 +1,173 @@
1
+ #--
2
+ # Cloud Foundry 2012.02.03 Beta
3
+ # Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
4
+ #
5
+ # This product is licensed to you under the Apache License, Version 2.0 (the "License").
6
+ # You may not use this product except in compliance with the License.
7
+ #
8
+ # This product includes a number of subcomponents with
9
+ # separate copyright notices and license terms. Your use of these
10
+ # subcomponents is subject to the terms and conditions of the
11
+ # subcomponent's license, as noted in the LICENSE file.
12
+ #++
13
+
14
+ # Web or Native Clients (in the OAuth2 sense) would use this class to get tokens
15
+ # that they can use to get access to resources
16
+
17
+ # Client Apps that want to get access on behalf of their users to
18
+ # resource servers need to get tokens via authcode and implicit flows,
19
+ # request scopes, etc., but they don't need to process tokens. This
20
+ # class is for these use cases.
21
+
22
+ require 'securerandom'
23
+ require 'uaa/http'
24
+
25
+ module CF::UAA
26
+
27
+ # The Token class holds access and refresh tokens as well as token meta-data
28
+ # such as token type and expiration time. The info hash MUST include
29
+ # access_token, token_type and scope (if granted scope differs from requested
30
+ # scope). It should include expires_in. It may include refresh_token, scope,
31
+ # and other values from the auth server.
32
+ class Token
33
+ attr_reader :info
34
+ def initialize(info); @info = info end
35
+ def auth_header; "#{info['token_type']} #{info['access_token']}" end
36
+ end
37
+
38
+ class TokenIssuer
39
+
40
+ include Http
41
+
42
+ def initialize(target, client_id, client_secret = nil, token_target = nil)
43
+ @target, @client_id, @client_secret = target, client_id, client_secret
44
+ @token_target = token_target || target
45
+ end
46
+
47
+ # login prompts for use by app to collect credentials for implicit grant
48
+ def prompts
49
+ reply = json_get @target, '/login'
50
+ return reply['prompts'] if reply && reply['prompts']
51
+ raise BadResponse, "No prompts in response from target #{@target}"
52
+ end
53
+
54
+ # gets an access token in a single call to the UAA with the client
55
+ # credentials used for authentication. The credentials arg should
56
+ # be an object such as a hash that can be converted to a json
57
+ # representation of the credential name/value pairs
58
+ # as specified by the information retrieved by #prompts
59
+ def implicit_grant_with_creds(credentials, scope = nil)
60
+ # this manufactured redirect_uri is a convention here, not part of OAuth2
61
+ redir_uri = "https://uaa.cloudfoundry.com/redirect/#{@client_id}"
62
+ uri = authorize_path_args("token", redir_uri, scope, state = SecureRandom.uuid)
63
+
64
+ # the accept header is only here so the uaa will issue error replies in json to aid debugging
65
+ headers = {'content-type' => 'application/x-www-form-urlencoded', 'accept' => 'application/json' }
66
+ body = URI.encode_www_form(credentials.merge('source' => 'credentials'))
67
+ status, body, headers = request(@target, :post, uri, body, headers)
68
+ raise BadResponse, "status #{status}" unless status == 302
69
+ req_uri, reply_uri = URI.parse(redir_uri), URI.parse(headers['location'])
70
+ fragment, reply_uri.fragment = reply_uri.fragment, nil
71
+ raise BadResponse, "bad location header" unless req_uri == reply_uri
72
+ parse_implicit_params(fragment, state)
73
+ rescue URI::Error => e
74
+ raise BadResponse, "bad location header in reply: #{e.message}"
75
+ end
76
+
77
+ # constructs a uri that the client is to return to the browser to direct
78
+ # the user to the authorization server to get an authcode. The redirect_uri
79
+ # is embedded in the returned uri so the authorization server can redirect
80
+ # the user back to the client app.
81
+ def implicit_uri(redirect_uri, scope = nil)
82
+ @target + authorize_path_args("token", redirect_uri, scope)
83
+ end
84
+
85
+ def implicit_grant(implicit_uri, callback_query)
86
+ in_params = Util.decode_form_to_hash(URI.parse(implicit_uri).query)
87
+ unless in_params['state'] && in_params['redirect_uri']
88
+ raise ArgumentError, "redirect must happen before implicit grant"
89
+ end
90
+ parse_implicit_params callback_query, in_params['state']
91
+ end
92
+
93
+ def autologin_uri(redirect_uri, credentials, scope = nil)
94
+ headers = {'content_type' => 'application/x-www-form-urlencoded', 'accept' => 'application/json',
95
+ 'authorization' => Http.basic_auth(@client_id, @client_secret) }
96
+ body = URI.encode_www_form(credentials)
97
+ reply = json_parse_reply(*request(@target, :post, "/autologin", body, headers))
98
+ raise BadResponse, "no autologin code in reply" unless reply['code']
99
+ @target + authorize_path_args('code', redirect_uri, scope, SecureRandom.uuid, code: reply[:code])
100
+ end
101
+
102
+ # constructs a uri that the client is to return to the browser to direct
103
+ # the user to the authorization server to get an authcode. The redirect_uri
104
+ # is embedded in the returned uri so the authorization server can redirect
105
+ # the user back to the client app.
106
+ def authcode_uri(redirect_uri, scope = nil)
107
+ @target + authorize_path_args('code', redirect_uri, scope)
108
+ end
109
+
110
+ def authcode_grant(authcode_uri, callback_query)
111
+ ac_params = Util.decode_form_to_hash(URI.parse(authcode_uri).query)
112
+ unless ac_params['state'] && ac_params['redirect_uri']
113
+ raise ArgumentError, "authcode redirect must happen before authcode grant"
114
+ end
115
+ begin
116
+ params = Util.decode_form_to_hash(callback_query)
117
+ authcode = params['code']
118
+ raise BadResponse unless params['state'] == ac_params['state'] && authcode
119
+ rescue URI::InvalidURIError, ArgumentError, BadResponse
120
+ raise BadResponse, "received invalid response from target #{@target}"
121
+ end
122
+ request_token('grant_type' => 'authorization_code', 'code' => authcode, 'redirect_uri' => ac_params['redirect_uri'])
123
+ end
124
+
125
+ def owner_password_grant(username, password, scope = nil)
126
+ request_token('grant_type' => 'password', 'username' => username, 'password' => password, 'scope' => scope)
127
+ end
128
+
129
+ def client_credentials_grant(scope = nil)
130
+ request_token('grant_type' => 'client_credentials', 'scope' => scope)
131
+ end
132
+
133
+ def refresh_token_grant(refresh_token, scope = nil)
134
+ request_token('grant_type' => 'refresh_token', 'refresh_token' => refresh_token, 'scope' => scope)
135
+ end
136
+
137
+ private
138
+
139
+ def parse_implicit_params(encoded_params, state)
140
+ params = Util.decode_form_to_hash(encoded_params)
141
+ raise BadResponse, "mismatched state" unless state && params.delete('state') == state
142
+ raise TargetError.new(params), "error response from #{@target}" if params['error']
143
+ raise BadResponse, "no type and token" unless params['token_type'] && params['access_token']
144
+ exp = params['expires_in'].to_i
145
+ params['expires_in'] = exp if exp.to_s == params['expires_in']
146
+ Token.new params
147
+ rescue URI::InvalidURIError, ArgumentError
148
+ raise BadResponse, "received invalid response from target #{@target}"
149
+ end
150
+
151
+ # returns a CF::UAA::Token object which includes the access token and metadata.
152
+ def request_token(params)
153
+ if scope = Util.arglist(params.delete('scope'))
154
+ params['scope'] = Util.strlist(scope)
155
+ end
156
+ headers = {'content-type' => 'application/x-www-form-urlencoded', 'accept' => 'application/json',
157
+ 'authorization' => Http.basic_auth(@client_id, @client_secret) }
158
+ body = URI.encode_www_form(params)
159
+ reply = json_parse_reply(*request(@token_target, :post, '/oauth/token', body, headers))
160
+ raise BadResponse unless reply['token_type'] && reply['access_token']
161
+ Token.new reply
162
+ end
163
+
164
+ def authorize_path_args(response_type, redirect_uri, scope, state = SecureRandom.uuid, args = {})
165
+ params = args.merge('client_id' => @client_id, 'response_type' => response_type, 'redirect_uri' => redirect_uri, 'state' => state)
166
+ params['scope'] = scope = Util.strlist(scope) if scope = Util.arglist(scope)
167
+ params['nonce'], params['response_type'] = state, "#{response_type} id_token" if scope && scope.include?('openid')
168
+ "/oauth/authorize?#{URI.encode_www_form(params)}"
169
+ end
170
+
171
+ end
172
+
173
+ end
data/lib/uaa/util.rb ADDED
@@ -0,0 +1,159 @@
1
+ #--
2
+ # Cloud Foundry 2012.02.03 Beta
3
+ # Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
4
+ #
5
+ # This product is licensed to you under the Apache License, Version 2.0 (the "License").
6
+ # You may not use this product except in compliance with the License.
7
+ #
8
+ # This product includes a number of subcomponents with
9
+ # separate copyright notices and license terms. Your use of these
10
+ # subcomponents is subject to the terms and conditions of the
11
+ # subcomponent's license, as noted in the LICENSE file.
12
+ #++
13
+
14
+ require 'multi_json'
15
+ require "base64"
16
+ require 'logger'
17
+ require 'uri'
18
+
19
+ # :nodoc:
20
+ module CF
21
+ # Namespace for Cloudfoundry UAA
22
+ module UAA end
23
+ end
24
+
25
+ class Logger # :nodoc:
26
+ Severity::TRACE = Severity::DEBUG - 1
27
+ def trace(progname, &blk); add(Logger::Severity::TRACE, nil, progname, &blk) end
28
+ def trace? ; @level <= Logger::Severity::TRACE end
29
+ end
30
+
31
+ module CF::UAA
32
+
33
+ # all CF::UAA exceptions are derived from UAAError
34
+ class UAAError < RuntimeError; end
35
+
36
+ # Authentication error
37
+ class AuthError < UAAError; end
38
+
39
+ # error for decoding tokens, base64 encoding, or json
40
+ class DecodeError < UAAError; end
41
+
42
+ # low level helper functions useful to the UAA client APIs
43
+ class Util
44
+
45
+ # http headers and various protocol tags tend to contain '-' characters,
46
+ # are intended to be case-insensitive, and often end up as keys in ruby
47
+ # hashes. The :undash style converts to lowercase for at least
48
+ # consistent case if not exactly case insensitive, and with '_' instead
49
+ # of '-' for ruby convention. :todash reverses :undash (except for case).
50
+ # :uncamel and :tocamel provide similar translations for camel-case keys.
51
+ # returns new key
52
+ def self.hash_key(k, style)
53
+ case style
54
+ when :undash then k.to_s.downcase.tr('-', '_').to_sym
55
+ when :todash then k.to_s.downcase.tr('_', '-')
56
+ when :uncamel then k.to_s.downcase.gsub(/([A-Z])([^A-Z]*)/,'_\1\2').to_sym
57
+ when :tocamel then k.to_s.gsub(/(_[a-z])([^_]*)/) { $1[1].upcase + $2 }
58
+ when :tosym then k.to_sym
59
+ when :tostr then k.to_s
60
+ when :down then k.to_s.downcase
61
+ when :none then k
62
+ else raise ArgumentError, "unknown hash key style: #{style}"
63
+ end
64
+ end
65
+
66
+ # modifies obj in place changing any hash keys to style (see hash_key).
67
+ # Recursively modifies subordinate hashes. Returns modified obj
68
+ def self.hash_keys!(obj, style = :none)
69
+ return obj if style == :none
70
+ return obj.each {|o| hash_keys!(o, style)} if obj.is_a? Array
71
+ return obj unless obj.is_a? Hash
72
+ newkeys, nk = {}, nil
73
+ obj.delete_if { |k, v|
74
+ hash_keys!(v, style)
75
+ newkeys[nk] = v unless (nk = hash_key(k, style)) == k
76
+ nk != k
77
+ }
78
+ obj.merge!(newkeys)
79
+ end
80
+
81
+ # makes a new copy of obj with hash keys to style (see hash_key).
82
+ # Recursively modifies subordinate hashes. Returns modified obj
83
+ def self.hash_keys(obj, style = :none)
84
+ return obj.collect {|o| hash_keys(o, style)} if obj.is_a? Array
85
+ return obj unless obj.is_a? Hash
86
+ obj.each_with_object({}) {|(k, v), h|
87
+ h[hash_key(k, style)] = hash_keys(v, style)
88
+ }
89
+ end
90
+
91
+ # Takes an x-www-form-urlencoded string and returns a hash of key value pairs.
92
+ # Useful for OAuth parameters. It raises an ArgumentError if a key occurs
93
+ # more than once, which is a restriction of OAuth query strings.
94
+ # OAuth parameters are case sensitive, scim parameters are case-insensitive
95
+ # See ietf rfc 6749 section 3.1.
96
+ def self.decode_form_to_hash(url_encoded_pairs, style = :none)
97
+ URI.decode_www_form(url_encoded_pairs).each_with_object({}) do |p, o|
98
+ k = hash_key(p[0], style)
99
+ raise ArgumentError, "duplicate keys in form parameters" if o[k]
100
+ o[k] = p[1]
101
+ end
102
+ rescue Exception => e
103
+ raise ArgumentError, e.message
104
+ end
105
+
106
+ def self.json(obj) MultiJson.dump(obj) end
107
+ def self.json_encode64(obj = {}) encode64(json(obj)) end
108
+ def self.json_decode64(str) json_parse(decode64(str)) end
109
+ def self.encode64(obj) Base64::urlsafe_encode64(obj).gsub(/=*$/, '') end
110
+ def self.decode64(str)
111
+ return unless str
112
+ pad = str.length % 4
113
+ str << '=' * (4 - pad) if pad > 0
114
+ Base64::urlsafe_decode64(str)
115
+ rescue ArgumentError
116
+ raise DecodeError, "invalid base64 encoding"
117
+ end
118
+
119
+ def self.json_parse(str, style = :none)
120
+ hash_keys!(MultiJson.load(str), style) if str && !str.empty?
121
+ rescue MultiJson::DecodeError
122
+ raise DecodeError, "json decoding error"
123
+ end
124
+
125
+ def self.truncate(obj, limit = 50)
126
+ return obj.to_s if limit == 0
127
+ limit = limit < 5 ? 1 : limit - 4
128
+ str = obj.to_s[0..limit]
129
+ str.length > limit ? str + '...': str
130
+ end
131
+
132
+ # many parameters in these classes can be given as arrays, or as a list of
133
+ # arguments separated by spaces or commas. This method handles the possible
134
+ # inputs and returns an array of arguments.
135
+ def self.arglist(arg, default_arg = nil)
136
+ arg = default_arg unless arg
137
+ return arg if arg.nil? || arg.respond_to?(:join)
138
+ raise ArgumentError, "arg must be Array or space|comma delimited strings" unless arg.respond_to?(:split)
139
+ arg.split(/[\s\,]+/).reject { |e| e.empty? }
140
+ end
141
+
142
+ # reverse of arglist, puts arrays of strings into a single, space-delimited string
143
+ def self.strlist(arg, delim = ' ')
144
+ arg.respond_to?(:join) ? arg.join(delim) : arg.to_s if arg
145
+ end
146
+
147
+ def self.default_logger(level = nil, sink = nil)
148
+ if sink || !@default_logger
149
+ @default_logger = Logger.new(sink || $stdout)
150
+ level = :info unless level
151
+ @default_logger.formatter = Proc.new { |severity, time, pname, msg| puts msg }
152
+ end
153
+ @default_logger.level = Logger::Severity.const_get(level.upcase) if level
154
+ @default_logger
155
+ end
156
+
157
+ end
158
+
159
+ end