lib/uaa/token_issuer.rb in cf-uaa-lib-1.3.1 vs lib/uaa/token_issuer.rb in cf-uaa-lib-1.3.2

- old
+ new

@@ -14,81 +14,130 @@ require 'securerandom' require 'uaa/http' module CF::UAA -# The Token class is returned by various TokenIssuer methods. It holds access +# The TokenInfo class is returned by various TokenIssuer methods. It holds access # and refresh tokens as well as token meta-data such as token type and -# expiration time. See Token#info for contents. -class Token +# expiration time. See {TokenInfo#info} for contents. +class TokenInfo - # Returns a hash of information about the current token. The info hash MUST include + # Information about the current token. The info hash MUST include # access_token, token_type and scope (if granted scope differs from requested # scope). It should include expires_in. It may include refresh_token, scope, # and other values from the auth server. + # @return [Hash] attr_reader :info - def initialize(info) # :nodoc: - @info = info + # Normally instantiated by {TokenIssuer}. + def initialize(info) @info = info end + + # Constructs a string for use in an authorization header from the contents of + # the TokenInfo. + # @return [String] Typically a string such as "bearer xxxx.xxxx.xxxx". + def auth_header + "#{@info[:token_type] || @info['token_type']} #{@info[:access_token] || @info['access_token']}" end - # Returns a string for use in an authorization header that is constructed - # from contents of the Token. Typically a string such as "bearer xxxx.xxxx.xxxx". - def auth_header; "#{info['token_type']} #{info['access_token']}" end end # Client Apps that want to get access to resource servers on behalf of their # users need to get tokens via authcode and implicit flows, # request scopes, etc., but they don't need to process tokens. This # class is for these use cases. # # In general most of this class is an implementation of the client pieces of -# the OAuth2 protocol. See http://tools.ietf.org/html/rfc6749 +# the OAuth2 protocol. See {http://tools.ietf.org/html/rfc6749} class TokenIssuer include Http - # parameters: - # [+target+] The base URL of a UAA's oauth authorize endpoint. For example - # the target would be \https://login.cloudfoundry.com if the - # endpoint is \https://login.cloudfoundry.com/oauth/authorize. - # The target would be \http://localhost:8080/uaa if the endpoint - # is \http://localhost:8080/uaa/oauth/authorize. - # [+client_id+] The oauth2 client id. See http://tools.ietf.org/html/rfc6749#section-2.2 - # [+client_secret+] needed to authenticate the client for all grant types - # except implicit. - # [+token_target+] The base URL of the oauth token endpoint. If not specified, - # +target+ is used. - def initialize(target, client_id, client_secret = nil, token_target = nil) + private + + def random_state; SecureRandom.hex end + + def parse_implicit_params(encoded_params, state) + params = Util.decode_form(encoded_params) + raise BadResponse, "mismatched state" unless state && params.delete('state') == state + raise TargetError.new(params), "error response from #{@target}" if params['error'] + raise BadResponse, "no type and token" unless params['token_type'] && params['access_token'] + exp = params['expires_in'].to_i + params['expires_in'] = exp if exp.to_s == params['expires_in'] + TokenInfo.new(Util.hash_keys!(params, @key_style)) + rescue URI::InvalidURIError, ArgumentError + raise BadResponse, "received invalid response from target #{@target}" + end + + # returns a CF::UAA::TokenInfo object which includes the access token and metadata. + def request_token(params) + if scope = Util.arglist(params.delete(:scope)) + params[:scope] = Util.strlist(scope) + end + headers = {'content-type' => 'application/x-www-form-urlencoded', + 'accept' => 'application/json', + 'authorization' => Http.basic_auth(@client_id, @client_secret) } + reply = json_parse_reply(@key_style, *request(@token_target, :post, + '/oauth/token', Util.encode_form(params), headers)) + raise BadResponse unless reply[jkey :token_type] && reply[jkey :access_token] + TokenInfo.new(reply) + end + + def authorize_path_args(response_type, redirect_uri, scope, state = random_state, args = {}) + params = args.merge(:client_id => @client_id, :response_type => response_type, + :redirect_uri => redirect_uri, :state => state) + params[:scope] = scope = Util.strlist(scope) if scope = Util.arglist(scope) + params[:nonce], params[:response_type] = state, "#{response_type} id_token" if scope && scope.include?('openid') + "/oauth/authorize?#{Util.encode_form(params)}" + end + + def jkey(k) @key_style ? k : k.to_s end + + public + + # @param [String] target The base URL of a UAA's oauth authorize endpoint. + # For example the target would be {https://login.cloudfoundry.com} if the + # endpoint is {https://login.cloudfoundry.com/oauth/authorize}. + # The target would be {http://localhost:8080/uaa} if the endpoint + # is {http://localhost:8080/uaa/oauth/authorize}. + # @param [String] client_id The oauth2 client id, see + # {http://tools.ietf.org/html/rfc6749#section-2.2} + # @param [String] client_secret Needed to authenticate the client for all + # grant types except implicit. + # @param [Hash] options can be + # * +:token_target+, the base URL of the oauth token endpoint -- if + # not specified, +target+ is used. + # * +:symbolize_keys+, if true, returned hash keys are symbols. + def initialize(target, client_id, client_secret = nil, options = {}) @target, @client_id, @client_secret = target, client_id, client_secret - @token_target = token_target || target + @token_target = options[:token_target] || target + @key_style = options[:symbolize_keys] ? :sym : nil end # Allows an app to discover what credentials are required for - # #implicit_grant_with_creds. Returns a hash of credential names with type - # and suggested prompt value, e.g. - # {"username":["text","Email"],"password":["password","Password"]} + # {#implicit_grant_with_creds}. + # @return [Hash] of credential names with type and suggested prompt value, + # e.g. !{"username":["text","Email"],"password":["password","Password"]} def prompts - reply = json_get @target, '/login' - return reply['prompts'] if reply && reply['prompts'] + reply = json_get(@target, '/login') + return reply[jkey :prompts] if reply && reply[jkey :prompts] raise BadResponse, "No prompts in response from target #{@target}" end # Gets an access token in a single call to the UAA with the user - # credentials used for authentication. The +credentials+ should - # be an object such as a hash that can be converted to a json - # representation of the credential name/value pairs - # corresponding to the keys retrieved by #prompts. - # Returns a Token. + # credentials used for authentication. + # @param credentials should be an object such as a hash that can be converted + # to a json representation of the credential name/value pairs corresponding to + # the keys retrieved by {#prompts}. + # @return [TokenInfo] def implicit_grant_with_creds(credentials, scope = nil) # this manufactured redirect_uri is a convention here, not part of OAuth2 redir_uri = "https://uaa.cloudfoundry.com/redirect/#{@client_id}" - uri = authorize_path_args("token", redir_uri, scope, state = SecureRandom.uuid) + uri = authorize_path_args("token", redir_uri, scope, state = random_state) # the accept header is only here so the uaa will issue error replies in json to aid debugging headers = {'content-type' => 'application/x-www-form-urlencoded', 'accept' => 'application/json' } - body = URI.encode_www_form(credentials.merge('source' => 'credentials')) + body = Util.encode_form(credentials.merge(:source => 'credentials')) status, body, headers = request(@target, :post, uri, body, headers) raise BadResponse, "status #{status}" unless status == 302 req_uri, reply_uri = URI.parse(redir_uri), URI.parse(headers['location']) fragment, reply_uri.fragment = reply_uri.fragment, nil raise BadResponse, "bad location header" unless req_uri == reply_uri @@ -96,144 +145,112 @@ rescue URI::Error => e raise BadResponse, "bad location header in reply: #{e.message}" end # Constructs a uri that the client is to return to the browser to direct - # the user to the authorization server to get an authcode. The +redirect_uri+ - # is embedded in the returned uri so the authorization server can redirect - # the user back to the client app. + # the user to the authorization server to get an authcode. + # @param [String] redirect_uri (see #authcode_uri) + # @return [String] def implicit_uri(redirect_uri, scope = nil) @target + authorize_path_args("token", redirect_uri, scope) end # Gets a token via an implicit grant. - # [+authcode_uri+] must be from a previous call to #implicit_uri and contains - # state used to validate the contents of the reply from the - # Authorization Server. - # [+callback_fragment+] must be the fragment portion of the URL received by - # user's browser after the Authorization Server - # redirects back to the +redirect_uri+ that was given to - # #implicit_uri. How the application get's the contents - # of the fragment is application specific -- usually - # some javascript in the page at the +redirect_uri+. - # - # See http://tools.ietf.org/html/rfc6749#section-4.2 . - # - # Returns a Token. + # @param [String] implicit_uri must be from a previous call to + # {#implicit_uri}, contains state used to validate the contents of the + # reply from the server. + # @param [String] callback_fragment must be the fragment portion of the URL + # received by the user's browser after the server redirects back to the + # +redirect_uri+ that was given to {#implicit_uri}. How the application + # gets the contents of the fragment is application specific -- usually + # some javascript in the page at the +redirect_uri+. + # @see http://tools.ietf.org/html/rfc6749#section-4.2 + # @return [TokenInfo] def implicit_grant(implicit_uri, callback_fragment) - in_params = Util.decode_form_to_hash(URI.parse(implicit_uri).query) + in_params = Util.decode_form(URI.parse(implicit_uri).query) unless in_params['state'] && in_params['redirect_uri'] raise ArgumentError, "redirect must happen before implicit grant" end - parse_implicit_params callback_fragment, in_params['state'] + parse_implicit_params(callback_fragment, in_params['state']) end # A UAA extension to OAuth2 that allows a client to pre-authenticate a # user at the start of an authorization code flow. By passing in the - # user's credentials (see #prompts) the Authorization Server can establish - # a session with the user's browser without reprompting for authentication. - # This is useful for user account management apps so that they can create - # a user account, or reset a password for the user, without requiring the - # user to type in their credentials again. + # user's credentials the server can establish a session with the user's + # browser without reprompting for authentication. This is useful for + # user account management apps so that they can create a user account, + # or reset a password for the user, without requiring the user to type + # in their credentials again. + # @param [String] credentials (see #implicit_grant_with_creds) + # @param [String] redirect_uri (see #authcode_uri) + # @return (see #authcode_uri) def autologin_uri(redirect_uri, credentials, scope = nil) - headers = {'content_type' => 'application/x-www-form-urlencoded', 'accept' => 'application/json', + headers = {'content-type' => 'application/x-www-form-urlencoded', + 'accept' => 'application/json', 'authorization' => Http.basic_auth(@client_id, @client_secret) } - body = URI.encode_www_form(credentials) - reply = json_parse_reply(*request(@target, :post, "/autologin", body, headers)) + body = Util.encode_form(credentials) + reply = json_parse_reply(nil, *request(@target, :post, "/autologin", body, headers)) raise BadResponse, "no autologin code in reply" unless reply['code'] - @target + authorize_path_args('code', redirect_uri, scope, SecureRandom.uuid, code: reply[:code]) + @target + authorize_path_args('code', redirect_uri, scope, + random_state, :code => reply['code']) end # Constructs a uri that the client is to return to the browser to direct - # the user to the authorization server to get an authcode. The redirect_uri - # is embedded in the returned uri so the authorization server can redirect - # the user back to the client app. + # the user to the authorization server to get an authcode. + # @param [String] redirect_uri is embedded in the returned uri so the server + # can redirect the user back to the caller's endpoint. + # @return [String] uri which def authcode_uri(redirect_uri, scope = nil) @target + authorize_path_args('code', redirect_uri, scope) end # Uses the instance client credentials in addition to +callback_query+ # to get a token via the authorization code grant. - # [+authcode_uri+] must be from a previous call to #authcode_uri and contains - # state used to validate the contents of the reply from the - # Authorization Server. - # [callback_query] must be the query portion of the URL received by the - # client after the user's browser is redirected back from - # the Authorization server. It contains the authorization - # code. - # - # See http://tools.ietf.org/html/rfc6749#section-4.1 . - # - # Returns a Token. + # @param [String] authcode_uri must be from a previous call to {#authcode_uri} + # and contains state used to validate the contents of the reply from the + # server. + # @param [String] callback_query must be the query portion of the URL + # received by the client after the user's browser is redirected back from + # the server. It contains the authorization code. + # @see http://tools.ietf.org/html/rfc6749#section-4.1 + # @return [TokenInfo] def authcode_grant(authcode_uri, callback_query) - ac_params = Util.decode_form_to_hash(URI.parse(authcode_uri).query) + ac_params = Util.decode_form(URI.parse(authcode_uri).query) unless ac_params['state'] && ac_params['redirect_uri'] raise ArgumentError, "authcode redirect must happen before authcode grant" end begin - params = Util.decode_form_to_hash(callback_query) + params = Util.decode_form(callback_query) authcode = params['code'] raise BadResponse unless params['state'] == ac_params['state'] && authcode rescue URI::InvalidURIError, ArgumentError, BadResponse raise BadResponse, "received invalid response from target #{@target}" end - request_token('grant_type' => 'authorization_code', 'code' => authcode, 'redirect_uri' => ac_params['redirect_uri']) + request_token(:grant_type => 'authorization_code', :code => authcode, + :redirect_uri => ac_params['redirect_uri']) end # Uses the instance client credentials in addition to the +username+ # and +password+ to get a token via the owner password grant. - # See http://tools.ietf.org/html/rfc6749#section-4.3 . - # Returns a Token. + # See {http://tools.ietf.org/html/rfc6749#section-4.3}. + # @return [TokenInfo] def owner_password_grant(username, password, scope = nil) - request_token('grant_type' => 'password', 'username' => username, 'password' => password, 'scope' => scope) + request_token(:grant_type => 'password', :username => username, + :password => password, :scope => scope) end # Uses the instance client credentials to get a token with a client # credentials grant. See http://tools.ietf.org/html/rfc6749#section-4.4 - # Returns a Token. + # @return [TokenInfo] def client_credentials_grant(scope = nil) - request_token('grant_type' => 'client_credentials', 'scope' => scope) + request_token(:grant_type => 'client_credentials', :scope => scope) end # Uses the instance client credentials and the given +refresh_token+ to get # a new access token. See http://tools.ietf.org/html/rfc6749#section-6 - # Returns a Token, which may include a new refresh token as well as an access token. + # @return [TokenInfo] which may include a new refresh token as well as an access token. def refresh_token_grant(refresh_token, scope = nil) - request_token('grant_type' => 'refresh_token', 'refresh_token' => refresh_token, 'scope' => scope) - end - - private - - def parse_implicit_params(encoded_params, state) - params = Util.decode_form_to_hash(encoded_params) - raise BadResponse, "mismatched state" unless state && params.delete('state') == state - raise TargetError.new(params), "error response from #{@target}" if params['error'] - raise BadResponse, "no type and token" unless params['token_type'] && params['access_token'] - exp = params['expires_in'].to_i - params['expires_in'] = exp if exp.to_s == params['expires_in'] - Token.new params - rescue URI::InvalidURIError, ArgumentError - raise BadResponse, "received invalid response from target #{@target}" - end - - # returns a CF::UAA::Token object which includes the access token and metadata. - def request_token(params) - if scope = Util.arglist(params.delete('scope')) - params['scope'] = Util.strlist(scope) - end - headers = {'content-type' => 'application/x-www-form-urlencoded', 'accept' => 'application/json', - 'authorization' => Http.basic_auth(@client_id, @client_secret) } - body = URI.encode_www_form(params) - reply = json_parse_reply(*request(@token_target, :post, '/oauth/token', body, headers)) - raise BadResponse unless reply['token_type'] && reply['access_token'] - Token.new reply - end - - def authorize_path_args(response_type, redirect_uri, scope, state = SecureRandom.uuid, args = {}) - params = args.merge('client_id' => @client_id, 'response_type' => response_type, 'redirect_uri' => redirect_uri, 'state' => state) - params['scope'] = scope = Util.strlist(scope) if scope = Util.arglist(scope) - params['nonce'], params['response_type'] = state, "#{response_type} id_token" if scope && scope.include?('openid') - "/oauth/authorize?#{URI.encode_www_form(params)}" + request_token(:grant_type => 'refresh_token', :refresh_token => refresh_token, :scope => scope) end end end