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

Commit

Permalink
Support RFC7636 PKCE in the OAuth 2.0 flow. (#14750)
Browse files Browse the repository at this point in the history
PKCE can protect against certain attacks and is enabled by default. Support
can be controlled manually by setting the pkce_method of each oidc_providers
entry to 'auto' (default), 'always', or 'never'.

This is required by Twitter OAuth 2.0 support.
  • Loading branch information
clokep authored Jan 4, 2023
1 parent 747f8eb commit 630d0ae
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 16 deletions.
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.
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`, `always`, or `never`. Defaults
to `auto`, which uses PKCE if supported during metadata discovery. Set to `always`
to force enable PKCE or `never` to force disable 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", "never"]},
"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', 'always', and 'never'.
pkce_method: str

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

Expand Down
54 changes: 47 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,11 @@ 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"]
elif self._config.pkce_method == "never":
metadata.pop("code_challenge_methods_supported", None)

self._validate_metadata(metadata)

return metadata
Expand Down Expand Up @@ -653,7 +669,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 +682,7 @@ async def _exchange_code(self, code: str) -> Token:
Args:
code: The authorization code we got from the callback.
code_verifier: The PKCE code verifier to send, blank if unused.
Returns:
A dict containing various tokens.
Expand Down Expand Up @@ -696,6 +713,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 @@ -914,11 +933,14 @@ async def handle_redirect_request(
- ``scope``: the list of scopes set in ``oidc_config.scopes``
- ``state``: a random string
- ``nonce``: a random string
- ``code_challenge``: a RFC7636 code challenge (if PKCE is supported)
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 +957,33 @@ async def handle_redirect_request(

state = generate_token()
nonce = generate_token()
code_verifier = ""

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 +1004,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 +1013,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 +1041,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 the RFC7636 code challenge ("" if PKCE is not being 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

0 comments on commit 630d0ae

Please sign in to comment.