cf-uaa-lib 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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