Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

oauth: feat: add support of PKCE #9287

Merged
merged 1 commit into from
Dec 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/defaults.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@
$config['oauth_client_secret'] = null;

// Optional: the OIDC discovery URI (the 'https://.../.well-known/openid-configuration')
// if specified, the discovery will supersede `oauth_issuer`, `auth_auth_uri`, `oauth_token_uri`, `oauth_identity_uri`, `oauth_logout_uri`, `oauth_jwks_uri`)
// if specified, the discovery will supersede `oauth_issuer`, `auth_auth_uri`, `oauth_token_uri`, `oauth_identity_uri`, `oauth_logout_uri`, `oauth_jwks_uri`
// it is recommanded to activate a cache via `oauth_cache` and `oauth_cache_ttl`
$config['oauth_config_uri'] = null;

Expand All @@ -366,6 +366,10 @@
// Mandatory: URI for OAuth user authentication (redirect)
$config['oauth_auth_uri'] = null;

// Optional: PKCE protection, by default it is enabled to S256 method, to disable it use `false`
// please note that `plain` method is voluntarily not implemented
$config['oauth_pkce'] = 'S256';

// Mandatory or Optional if $oauth_config_uri is specified: Endpoint for OAuth authentication requests (server-to-server)
$config['oauth_token_uri'] = null;

Expand Down
116 changes: 91 additions & 25 deletions program/include/rcmail_oauth.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ class rcmail_oauth
'jwks_uri' => 'jwks_uri',
];

/** @var array map PKCE code_challenge_method to hash method */
static protected $pkce_mapper = [
'S256' => 'sha256',
// plain method is not implemented: @see RFC7636 4.2: "If the client is capable of using "S256", it MUST use "S256"
];

/** @var rcmail_oauth */
static protected $instance;

Expand Down Expand Up @@ -155,6 +161,7 @@ public function __construct($options = [])
'verify_peer' => $this->rcmail->config->get('oauth_verify_peer', true),
'auth_parameters' => $this->rcmail->config->get('oauth_auth_parameters', []),
'login_redirect' => $this->rcmail->config->get('oauth_login_redirect', false),
'pkce' => $this->rcmail->config->get('oauth_pkce', 'S256'),
'debug' => $this->rcmail->config->get('oauth_debug', false),
];

Expand All @@ -163,6 +170,15 @@ public function __construct($options = [])
$options['http_options'] = [];
}

if ($this->options['pkce'] && !array_key_exists($this->options['pkce'], self::$pkce_mapper)) {
// will stops on error
rcube::raise_error([
'message' => "PKCE method not supported (oauth_pkce='{$this->options['pkce']}')",
'file' => __FILE__,
'line' => __LINE__,
], true, true);
}

// prepare a http client with the correct options
$this->http_client = $this->rcmail->get_http_client((array) $options['http_options'] + [
'timeout' => $this->options['timeout'],
Expand Down Expand Up @@ -220,6 +236,18 @@ protected function discover()
$this->options[$options_key] = $data[$config_key];
}
}

// check if pkce method is supported by this server
if ($this->options['pkce'] && isset($data['code_challenge_methods_supported']) && is_array($data['code_challenge_methods_supported'])) {
if (!in_array($this->options['pkce'], $data['code_challenge_methods_supported'])) {
rcube::raise_error([
'message' => "OAuth server does not support this PKCE method (oauth_pkce='{$this->options['pkce']}')",
'file' => __FILE__,
'line' => __LINE__,
], true, false
);
}
}
}
catch (\Exception $e) {
rcube::raise_error([
Expand Down Expand Up @@ -362,11 +390,16 @@ public function loginform_content(array $form_content)
}


public static function base64url_decode($encoded)
protected static function base64url_decode($encoded)
{
return base64_decode(strtr($encoded, '-_', '+/'), true);
}

protected static function base64url_encode($payload)
{
return rtrim(strtr(base64_encode($payload), '+/', '-_'),'=');
}

/**
* Helper method to decode a JWT and check payload OIDC consistency
*
Expand Down Expand Up @@ -454,7 +487,7 @@ public function get_redirect_uri()
/**
* Login action: redirect to `oauth_auth_uri`
*
* Authorization Request
* Authorization Code Request
*
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
*
Expand All @@ -477,19 +510,37 @@ public function login_redirect()
$_SESSION['oauth_state'] = rcube_utils::random_bytes(12);

// compose full oauth login uri
$delimiter = strpos($this->options['auth_uri'], '?') > 0 ? '&' : '?';
$query = http_build_query([
$query = [
'response_type' => 'code',
'client_id' => $this->options['client_id'],
'scope' => $this->options['scope'],
'redirect_uri' => $this->get_redirect_uri(),
'state' => $_SESSION['oauth_state'],
] + (array) $this->options['auth_parameters']);
];

// implementation of PKCE @see: rfc7636
if ($this->options['pkce']) {
$code_verifier = rcube_utils::random_bytes(64);
$code_challenge_method = $this->options['pkce'];
$hash_method = self::$pkce_mapper[$code_challenge_method];

// do not store it in clear, do not want it to be readable
$_SESSION['oauth_code_verifier'] = $this->rcmail->encrypt($code_verifier);

$this->log_debug("requesting authorization with scope: %s", $this->options['scope']);
$query += [
'code_challenge_method' => $code_challenge_method,
'code_challenge' => self::base64url_encode(hash($hash_method, $code_verifier, true)),
];
}

$this->log_debug("requesting authorization code via a redirect to %s with scope='%s' and pkce method=%s",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just thinking maybe logging the full url instead would be better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it to avoid leak of sensitive data like the nonce in other pull request

$this->options['auth_uri'], $this->options['scope'], $this->options['pkce']);

$delimiter = strpos($this->options['auth_uri'], '?') > 0 ? '&' : '?';
$url = $this->options['auth_uri'] . $delimiter . http_build_query($query + (array) $this->options['auth_parameters']);

$this->last_error = null; // clean last error
$this->rcmail->output->redirect($this->options['auth_uri'] . $delimiter . $query); // exit
$this->rcmail->output->redirect($url); // exit
}

/**
Expand Down Expand Up @@ -554,16 +605,19 @@ public function request_access_token($auth_code, $state = null)

$this->log_debug("requesting a grant_type=authorization_code to %s", $oauth_token_uri);

$response = $this->http_client->post($oauth_token_uri, [
'form_params' => [
'grant_type' => 'authorization_code',
'code' => $auth_code,
'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret,
'redirect_uri' => $this->get_redirect_uri(),
],
]);
$form = [
'grant_type' => 'authorization_code',
'code' => $auth_code,
'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret,
'redirect_uri' => $this->get_redirect_uri(),
];

if ($this->options['pkce']) {
$form['code_verifier'] = $this->rcmail->decrypt($_SESSION['oauth_code_verifier']);
}

$response = $this->http_client->post($oauth_token_uri, ['form_params' => $form]);
$data = json_decode($response->getBody(), true);

$authorization = $this->parse_tokens('authorization_code', $data);
Expand Down Expand Up @@ -623,6 +677,11 @@ public function request_access_token($auth_code, $state = null)
'authorization' => $authorization, // the payload to authentificate through IMAP, SMTP, SIEVE .. servers
'token' => $data,
];

if ($this->options['pkce']) {
// store crypted code_verifier because session is going to be killed
$this->login_phase['code_verifier'] = $_SESSION['oauth_code_verifier'];
}
return $this->login_phase;
}
catch (RequestException $e) {
Expand Down Expand Up @@ -674,14 +733,18 @@ public function refresh_access_token(array $token)
try {
$this->log_debug("requesting a grant_type=refresh_token to %s", $oauth_token_uri);

$response = $this->http_client->post($oauth_token_uri, [
'form_params' => [
'grant_type' => 'refresh_token',
'refresh_token' => $this->rcmail->decrypt($token['refresh_token']),
'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret,
],
]);
$form = [
'grant_type' => 'refresh_token',
'refresh_token' => $this->rcmail->decrypt($token['refresh_token']),
'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret,
];

if ($this->options['pkce']) {
$form['code_verifier'] = $this->rcmail->decrypt($_SESSION['oauth_code_verifier']);
}

$response = $this->http_client->post($oauth_token_uri, ['form_params' => $form]);
$data = json_decode($response->getBody(), true);

$authorization = $this->parse_tokens('refresh_token', $data, $token);
Expand Down Expand Up @@ -1054,8 +1117,11 @@ public function login_after($options)
return;
}

// save OAuth token in session
// store important data to new freshly created session
$_SESSION['oauth_token'] = $this->login_phase['token'];
if ($this->options['pkce']) {
$_SESSION['oauth_code_verifier'] = $this->login_phase['code_verifier'];
}

$this->log_debug('login successful for OIDC sub=%s with username=%s which is rcube-id=%s',
$this->login_phase['token']['identity']['sub'], $this->login_phase['username'], $this->rcmail->user->ID);
Expand Down