From 90e422b7a25c8595f2a5c249bd1a2269b066e473 Mon Sep 17 00:00:00 2001 From: Markus Strehle <11627201+strehle@users.noreply.github.com> Date: Tue, 29 Aug 2023 06:25:10 +0200 Subject: [PATCH] feature: authorization_code grant with public client usage (#90) * feature: authoriation_code grant with public client usage * add PKCE to authorization code * optional allow to omit client_secret * add client_auth_method to class to distinguish between basic and post - later private_key_jwt * add example ruby script * add default as it was before * test update * add extra option for pkce and set it to false * review removed client_secret_post for now The methods itself are useful therefore add it later with extra PR renamed the test PKCE in cf-uaa-lib is active if a) you provide a secret for the calculation b) you set use_pkce=true in initialization of the lib By default PKCE is off. * less code, less logic. Tests not touched --- examples/authorization_grant_public_pkce.rb | 42 +++++++++++++++++++ lib/uaa/token_issuer.rb | 45 +++++++++++++++++++-- spec/token_issuer_spec.rb | 35 ++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 examples/authorization_grant_public_pkce.rb diff --git a/examples/authorization_grant_public_pkce.rb b/examples/authorization_grant_public_pkce.rb new file mode 100644 index 0000000..55878fd --- /dev/null +++ b/examples/authorization_grant_public_pkce.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby + +# Start a develop UAA with default profile or add client with allowpublic=true +# uaac client add login -s loginsecret \ +# --authorized_grant_types authorization_code,refresh_token \ +# --scope "openid" \ +# --authorities uaa.none \ +# --allowpublic true \ +# --redirect_uri=http://localhost:7000/callback + +require 'uaa' +require 'cgi' + +url = ENV["UAA_URL"] || 'http://localhost:8080/uaa' +client = "login" +secret = nil + +def show(title, object) + puts "#{title}: #{object.inspect}" + puts +end + +uaa_options = { skip_ssl_validation: true, use_pkce:true, client_auth_method: 'none'} +uaa_options[:ssl_ca_file] = ENV["UAA_CA_CERT_FILE"] if ENV["UAA_CA_CERT_FILE"] +show "uaa_options", uaa_options + +uaa_info = CF::UAA::Info.new(url, uaa_options) +show "UAA server info", uaa_info.server + +token_issuer = CF::UAA::TokenIssuer.new(url, client, secret, uaa_options) +auth_uri = token_issuer.authcode_uri("http://localhost:7000/callback", nil) +show "UAA authorization URL", auth_uri + +puts "Enter Callback URL: " +callback_url = gets +show "Perform Token Request with: ", callback_url + +token = token_issuer.authcode_grant(auth_uri, URI.parse(callback_url).query.to_s) +show "User authorization grant", token + +token_info = CF::UAA::TokenCoder.decode(token.info["access_token"], nil, nil, false) #token signature not verified +show "Decoded access token", token_info diff --git a/lib/uaa/token_issuer.rb b/lib/uaa/token_issuer.rb index 2aefdc2..ff5c8b8 100644 --- a/lib/uaa/token_issuer.rb +++ b/lib/uaa/token_issuer.rb @@ -12,6 +12,7 @@ #++ require 'securerandom' +require "digest" require 'uaa/http' require 'cgi' @@ -53,6 +54,7 @@ class TokenIssuer include Http private + @client_auth_method = 'client_secret_basic' def random_state; SecureRandom.hex end @@ -74,8 +76,15 @@ def request_token(params) params[:scope] = Util.strlist(scope) end headers = {'content-type' => FORM_UTF8, 'accept' => JSON_UTF8} - if @basic_auth - headers['authorization'] = Http.basic_auth(@client_id, @client_secret) + if @client_auth_method == 'client_secret_basic' && @client_secret && @client_id + if @basic_auth + headers['authorization'] = Http.basic_auth(@client_id, @client_secret) + else + headers['X-CF-ENCODED-CREDENTIALS'] = 'true' + headers['authorization'] = Http.basic_auth(CGI.escape(@client_id), CGI.escape(@client_secret)) + end + elsif @client_id && params[:code_verifier] + params[:client_id] = @client_id else headers['X-CF-ENCODED-CREDENTIALS'] = 'true' headers['authorization'] = Http.basic_auth(CGI.escape(@client_id || ''), CGI.escape(@client_secret || '')) @@ -91,6 +100,10 @@ def authorize_path_args(response_type, redirect_uri, scope, state = random_state redirect_uri: redirect_uri, state: state) params[:scope] = scope = Util.strlist(scope) if scope = Util.arglist(scope) params[:nonce] = state + if not @code_verifier.nil? + params[:code_challenge] = get_challenge + params[:code_challenge_method] = 'S256' + end "/oauth/authorize?#{Util.encode_form(params)}" end @@ -116,6 +129,11 @@ def initialize(target, client_id, client_secret = nil, options = {}) @token_target = options[:token_target] || target @key_style = options[:symbolize_keys] ? :sym : nil @basic_auth = options[:basic_auth] == true ? true : false + @client_auth_method = options[:client_auth_method] || 'client_secret_basic' + @code_verifier = options[:code_verifier] || nil + if @code_verifier.nil? && options[:use_pkce] && options[:use_pkce] == true + @code_verifier = get_verifier + end initialize_http_options(options) end @@ -235,8 +253,27 @@ def authcode_grant(authcode_uri, callback_query) 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']) + if not @code_verifier.nil? + request_token(grant_type: 'authorization_code', code: authcode, + redirect_uri: ac_params['redirect_uri'], code_verifier: @code_verifier) + else + request_token(grant_type: 'authorization_code', code: authcode, + redirect_uri: ac_params['redirect_uri']) + end + end + + # Generates a random verifier for PKCE usage + def get_verifier + if not @code_verifier.nil? + @verifier = @code_verifier + else + @verifier ||= SecureRandom.base64(96).tr("+/", "-_").tr("=", "") + end + end + + # Calculates the challenge from code_verifier + def get_challenge + @challenge ||= Digest::SHA256.base64digest(get_verifier).tr("+/", "-_").tr("=", "") end # Uses the instance client credentials in addition to the +username+ diff --git a/spec/token_issuer_spec.rb b/spec/token_issuer_spec.rb index 0d251ef..f5e26b9 100644 --- a/spec/token_issuer_spec.rb +++ b/spec/token_issuer_spec.rb @@ -264,6 +264,7 @@ module CF::UAA end context 'with auth code grant' do + let(:options) { {use_pkce: true} } it 'gets the authcode uri to be sent to the user agent for an authcode' do redir_uri = 'http://call.back/uri_path' @@ -275,6 +276,8 @@ module CF::UAA params['scope'].should == 'openid' params['redirect_uri'].should == redir_uri params['state'].should_not be_nil + params['code_challenge'].should =~ /^[0-9A-Za-z_-]{43}$/i + params['code_challenge_method'].should == 'S256' end it 'gets an access token with an authorization code' do @@ -292,6 +295,10 @@ module CF::UAA cburi = 'http://call.back/uri_path' redir_uri = subject.authcode_uri(cburi) state = /state=([^&]+)/.match(redir_uri)[1] + challenge = /code_challenge=([^&]+)/.match(redir_uri)[1] + challenge.should =~ /^[0-9A-Za-z_-]{43}$/i + challenge_method = /code_challenge_method=([^&]+)/.match(redir_uri)[1] + challenge_method.should == 'S256' reply_query = "state=#{state}&code=kz8%2F5gQZ2pc%3D" token = subject.authcode_grant(redir_uri, reply_query) token.should be_an_instance_of TokenInfo @@ -303,6 +310,34 @@ module CF::UAA end + context 'pkce with own code verifier' do + let(:options) { {basic_auth: false, code_verifier: 'umoq1e_4XMYXvfHlaO9mSlSI17OKfxnwfR5ZD-oYreFxyn8yQZ-ZHPZfUZ4n3WjY_tkOB_MAisSy4ddqsa6aoTU5ZOcX4ps3de933PczYlC8pZpKL8EQWaDZOnpOyB2W'} } + + it 'calculate code_challenge on existing verifier' do + redir_uri = 'http://call.back/uri_path' + uri_parts = subject.authcode_uri(redir_uri, 'openid').split('?') + code_challenge = subject.get_challenge + code_verifier = subject.get_verifier + params = Util.decode_form(uri_parts[1]) + params['code_challenge'].should == code_challenge + params['code_challenge_method'].should == 'S256' + code_verifier.should == options[:code_verifier] + code_challenge.should == 'TAnM2AKGgiQKOC16cRpMdF_55qwmz3B333cq6T18z0s' + end + end + + context 'no pkce active as this is the default' do + #let(:options) { {use_pkce: false} } + # by default PKCE is off + it 'no code pkce generation with an authorization code' do + redir_uri = 'http://call.back/uri_path' + uri_parts = subject.authcode_uri(redir_uri, 'openid').split('?') + params = Util.decode_form(uri_parts[1]) + params['code_challenge'].should_not + params['code_challenge_method'].should_not + end + end + end end