Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Support PKCE for OAuth 2.0 #14750

Merged
merged 9 commits into from
Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions changelog.d/14750.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support [RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) Proof Key for Code Exchange for OAuth single sign-on.
dkasak marked this conversation as resolved.
Show resolved Hide resolved
7 changes: 6 additions & 1 deletion docs/usage/configuration/config_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3053,8 +3053,13 @@ Options for each entry include:
values are `client_secret_basic` (default), `client_secret_post` and
`none`.

* `pkce_method`: Whether to use proof key for code exchange when requesting
and exchanging the token. Valid values are: `auto` or `always`. Defaults to
`auto`, which uses PKCE if supported during metadata discovery. Set to `always`
Copy link
Contributor

Choose a reason for hiding this comment

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

first thoughts: are we missing a 'never' option?

Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't really want to add that unless we have to, but it is easy enough if you'd like. (I think it would just be a slight tweak to the load metadata code.)

Copy link
Member Author

Choose a reason for hiding this comment

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

We talked a bit about this in #synapse-dev:matrix.org, I think the tl;dr is that there's no value in disabling this unless you have an implementation which:

  1. Advertises support for the S256 variant of PKCE.
  2. Incorrectly implements that support.

This seems unlikely to me, but does match what @sandhose suggested initially: #14750 (comment)

I think the change is small, which makes sense to have an escape hatch to avoid blocking folks from upgrading.

Copy link
Member Author

Choose a reason for hiding this comment

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

@reivilibre Would you mind taking another quick look at this?

to always enable PKCE.

* `scopes`: list of scopes to request. This should normally include the "openid"
scope. Defaults to ["openid"].
scope. Defaults to `["openid"]`.

* `authorization_endpoint`: the oauth2 authorization endpoint. Required if
provider discovery is disabled.
Expand Down
6 changes: 6 additions & 0 deletions synapse/config/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def oidc_enabled(self) -> bool:
# to avoid importing authlib here.
"enum": ["client_secret_basic", "client_secret_post", "none"],
},
"pkce_method": {"type": "string", "enum": ["auto", "always"]},
"scopes": {"type": "array", "items": {"type": "string"}},
"authorization_endpoint": {"type": "string"},
"token_endpoint": {"type": "string"},
Expand Down Expand Up @@ -289,6 +290,7 @@ def _parse_oidc_config_dict(
client_secret=oidc_config.get("client_secret"),
client_secret_jwt_key=client_secret_jwt_key,
client_auth_method=oidc_config.get("client_auth_method", "client_secret_basic"),
pkce_method=oidc_config.get("pkce_method", "auto"),
scopes=oidc_config.get("scopes", ["openid"]),
authorization_endpoint=oidc_config.get("authorization_endpoint"),
token_endpoint=oidc_config.get("token_endpoint"),
Expand Down Expand Up @@ -357,6 +359,10 @@ class OidcProviderConfig:
# 'none'.
client_auth_method: str

# Whether to enable PKCE when exchanging the authorization & token.
# Valid values are 'auto', and 'always'.
pkce_method: str

# list of scopes to request
scopes: Collection[str]

Expand Down
51 changes: 44 additions & 7 deletions synapse/handlers/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from authlib.jose.errors import InvalidClaimError, JoseError, MissingClaimError
from authlib.oauth2.auth import ClientAuth
from authlib.oauth2.rfc6749.parameters import prepare_grant_uri
from authlib.oauth2.rfc7636.challenge import create_s256_code_challenge
from authlib.oidc.core import CodeIDToken, UserInfo
from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url
from jinja2 import Environment, Template
Expand Down Expand Up @@ -475,6 +476,16 @@ def _validate_metadata(self, m: OpenIDProviderMetadata) -> None:
)
)

# If PKCE support is advertised ensure the wanted method is available.
if m.get("code_challenge_methods_supported") is not None:
m.validate_code_challenge_methods_supported()
if "S256" not in m["code_challenge_methods_supported"]:
raise ValueError(
'"S256" not in "code_challenge_methods_supported" ({supported!r})'.format(
supported=m["code_challenge_methods_supported"],
)
)

if m.get("response_types_supported") is not None:
m.validate_response_types_supported()

Expand Down Expand Up @@ -602,6 +613,9 @@ async def _load_metadata(self) -> OpenIDProviderMetadata:
if self._config.jwks_uri:
metadata["jwks_uri"] = self._config.jwks_uri

if self._config.pkce_method == "always":
metadata["code_challenge_methods_supported"] = ["S256"]

self._validate_metadata(metadata)

return metadata
Expand Down Expand Up @@ -653,7 +667,7 @@ async def _load_jwks(self) -> JWKS:

return jwk_set

async def _exchange_code(self, code: str) -> Token:
async def _exchange_code(self, code: str, code_verifier: str) -> Token:
"""Exchange an authorization code for a token.

This calls the ``token_endpoint`` with the authorization code we
Expand All @@ -666,6 +680,7 @@ async def _exchange_code(self, code: str) -> Token:

Args:
code: The authorization code we got from the callback.
code_verifier: The code verifier to send, blank if unused.
clokep marked this conversation as resolved.
Show resolved Hide resolved

Returns:
A dict containing various tokens.
Expand Down Expand Up @@ -696,6 +711,8 @@ async def _exchange_code(self, code: str) -> Token:
"code": code,
"redirect_uri": self._callback_url,
}
if code_verifier:
args["code_verifier"] = code_verifier
body = urlencode(args, True)

# Fill the body/headers with credentials
Expand Down Expand Up @@ -915,10 +932,12 @@ async def handle_redirect_request(
- ``state``: a random string
- ``nonce``: a random string
clokep marked this conversation as resolved.
Show resolved Hide resolved

In addition generating a redirect URL, we are setting a cookie with
a signed macaroon token containing the state, the nonce and the
client_redirect_url params. Those are then checked when the client
comes back from the provider.
In addition to generating a redirect URL, we are setting a cookie with
a signed macaroon token containing the state, the nonce, the
client_redirect_url, and (optionally) the code_verifier params. The state,
nonce, and client_redirect_url are then checked when the client comes back
from the provider. The code_verifier is passed back to the server during
the token exchange and compared to the code_challenge sent in this request.

Args:
request: the incoming request from the browser.
Expand All @@ -935,17 +954,33 @@ async def handle_redirect_request(

clokep marked this conversation as resolved.
Show resolved Hide resolved
state = generate_token()
nonce = generate_token()
code_verifier = ""
Copy link
Member

Choose a reason for hiding this comment

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

I wondered why you weren't making that optional, but then I remembered we rely on macaroons for those... :'(

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeahhhh, I figured this was the cleanest way to do it.


if not client_redirect_url:
client_redirect_url = b""

metadata = await self.load_metadata()

# Automatically enable PKCE if it is supported.
extra_grant_values = {}
if metadata.get("code_challenge_methods_supported"):
code_verifier = generate_token(48)

# Note that we verified the server supports S256 earlier (in
# OidcProvider._validate_metadata).
extra_grant_values = {
"code_challenge_method": "S256",
"code_challenge": create_s256_code_challenge(code_verifier),
}

cookie = self._macaroon_generaton.generate_oidc_session_token(
state=state,
session_data=OidcSessionData(
idp_id=self.idp_id,
nonce=nonce,
client_redirect_url=client_redirect_url.decode(),
ui_auth_session_id=ui_auth_session_id or "",
code_verifier=code_verifier,
),
)

Expand All @@ -966,7 +1001,6 @@ async def handle_redirect_request(
)
)

metadata = await self.load_metadata()
authorization_endpoint = metadata.get("authorization_endpoint")
return prepare_grant_uri(
authorization_endpoint,
Expand All @@ -976,6 +1010,7 @@ async def handle_redirect_request(
scope=self._scopes,
state=state,
nonce=nonce,
**extra_grant_values,
)

async def handle_oidc_callback(
Expand Down Expand Up @@ -1003,7 +1038,9 @@ async def handle_oidc_callback(
# Exchange the code with the provider
try:
logger.debug("Exchanging OAuth2 code for a token")
token = await self._exchange_code(code)
token = await self._exchange_code(
code, code_verifier=session_data.code_verifier
)
except OidcError as e:
logger.warning("Could not exchange OAuth2 code: %s", e)
self._sso_handler.render_error(request, e.error, e.error_description)
Expand Down
7 changes: 7 additions & 0 deletions synapse/util/macaroons.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ class OidcSessionData:
ui_auth_session_id: str
"""The session ID of the ongoing UI Auth ("" if this is a login)"""

code_verifier: str
"""The random string used in code challenges ("" if code challenges are not used)."""


class MacaroonGenerator:
def __init__(self, clock: Clock, location: str, secret_key: bytes):
Expand Down Expand Up @@ -187,6 +190,7 @@ def generate_oidc_session_token(
macaroon.add_first_party_caveat(
f"ui_auth_session_id = {session_data.ui_auth_session_id}"
)
macaroon.add_first_party_caveat(f"code_verifier = {session_data.code_verifier}")
macaroon.add_first_party_caveat(f"time < {expiry}")

return macaroon.serialize()
Expand Down Expand Up @@ -278,6 +282,7 @@ def verify_oidc_session_token(self, session: bytes, state: str) -> OidcSessionDa
v.satisfy_general(lambda c: c.startswith("idp_id = "))
v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
v.satisfy_general(lambda c: c.startswith("code_verifier = "))
satisfy_expiry(v, self._clock.time_msec)

v.verify(macaroon, self._secret_key)
Expand All @@ -287,11 +292,13 @@ def verify_oidc_session_token(self, session: bytes, state: str) -> OidcSessionDa
idp_id = get_value_from_macaroon(macaroon, "idp_id")
client_redirect_url = get_value_from_macaroon(macaroon, "client_redirect_url")
ui_auth_session_id = get_value_from_macaroon(macaroon, "ui_auth_session_id")
code_verifier = get_value_from_macaroon(macaroon, "code_verifier")
return OidcSessionData(
nonce=nonce,
idp_id=idp_id,
client_redirect_url=client_redirect_url,
ui_auth_session_id=ui_auth_session_id,
code_verifier=code_verifier,
)

def _generate_base_macaroon(self, type: MacaroonType) -> pymacaroons.Macaroon:
Expand Down
Loading