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

Commit 630d0ae

Browse files
authored
Support RFC7636 PKCE in the OAuth 2.0 flow. (#14750)
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.
1 parent 747f8eb commit 630d0ae

File tree

7 files changed

+212
-16
lines changed

7 files changed

+212
-16
lines changed

changelog.d/14750.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support [RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) Proof Key for Code Exchange for OAuth single sign-on.

docs/usage/configuration/config_documentation.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3053,8 +3053,13 @@ Options for each entry include:
30533053
values are `client_secret_basic` (default), `client_secret_post` and
30543054
`none`.
30553055

3056+
* `pkce_method`: Whether to use proof key for code exchange when requesting
3057+
and exchanging the token. Valid values are: `auto`, `always`, or `never`. Defaults
3058+
to `auto`, which uses PKCE if supported during metadata discovery. Set to `always`
3059+
to force enable PKCE or `never` to force disable PKCE.
3060+
30563061
* `scopes`: list of scopes to request. This should normally include the "openid"
3057-
scope. Defaults to ["openid"].
3062+
scope. Defaults to `["openid"]`.
30583063

30593064
* `authorization_endpoint`: the oauth2 authorization endpoint. Required if
30603065
provider discovery is disabled.

synapse/config/oidc.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def oidc_enabled(self) -> bool:
117117
# to avoid importing authlib here.
118118
"enum": ["client_secret_basic", "client_secret_post", "none"],
119119
},
120+
"pkce_method": {"type": "string", "enum": ["auto", "always", "never"]},
120121
"scopes": {"type": "array", "items": {"type": "string"}},
121122
"authorization_endpoint": {"type": "string"},
122123
"token_endpoint": {"type": "string"},
@@ -289,6 +290,7 @@ def _parse_oidc_config_dict(
289290
client_secret=oidc_config.get("client_secret"),
290291
client_secret_jwt_key=client_secret_jwt_key,
291292
client_auth_method=oidc_config.get("client_auth_method", "client_secret_basic"),
293+
pkce_method=oidc_config.get("pkce_method", "auto"),
292294
scopes=oidc_config.get("scopes", ["openid"]),
293295
authorization_endpoint=oidc_config.get("authorization_endpoint"),
294296
token_endpoint=oidc_config.get("token_endpoint"),
@@ -357,6 +359,10 @@ class OidcProviderConfig:
357359
# 'none'.
358360
client_auth_method: str
359361

362+
# Whether to enable PKCE when exchanging the authorization & token.
363+
# Valid values are 'auto', 'always', and 'never'.
364+
pkce_method: str
365+
360366
# list of scopes to request
361367
scopes: Collection[str]
362368

synapse/handlers/oidc.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from authlib.jose.errors import InvalidClaimError, JoseError, MissingClaimError
3737
from authlib.oauth2.auth import ClientAuth
3838
from authlib.oauth2.rfc6749.parameters import prepare_grant_uri
39+
from authlib.oauth2.rfc7636.challenge import create_s256_code_challenge
3940
from authlib.oidc.core import CodeIDToken, UserInfo
4041
from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url
4142
from jinja2 import Environment, Template
@@ -475,6 +476,16 @@ def _validate_metadata(self, m: OpenIDProviderMetadata) -> None:
475476
)
476477
)
477478

479+
# If PKCE support is advertised ensure the wanted method is available.
480+
if m.get("code_challenge_methods_supported") is not None:
481+
m.validate_code_challenge_methods_supported()
482+
if "S256" not in m["code_challenge_methods_supported"]:
483+
raise ValueError(
484+
'"S256" not in "code_challenge_methods_supported" ({supported!r})'.format(
485+
supported=m["code_challenge_methods_supported"],
486+
)
487+
)
488+
478489
if m.get("response_types_supported") is not None:
479490
m.validate_response_types_supported()
480491

@@ -602,6 +613,11 @@ async def _load_metadata(self) -> OpenIDProviderMetadata:
602613
if self._config.jwks_uri:
603614
metadata["jwks_uri"] = self._config.jwks_uri
604615

616+
if self._config.pkce_method == "always":
617+
metadata["code_challenge_methods_supported"] = ["S256"]
618+
elif self._config.pkce_method == "never":
619+
metadata.pop("code_challenge_methods_supported", None)
620+
605621
self._validate_metadata(metadata)
606622

607623
return metadata
@@ -653,7 +669,7 @@ async def _load_jwks(self) -> JWKS:
653669

654670
return jwk_set
655671

656-
async def _exchange_code(self, code: str) -> Token:
672+
async def _exchange_code(self, code: str, code_verifier: str) -> Token:
657673
"""Exchange an authorization code for a token.
658674
659675
This calls the ``token_endpoint`` with the authorization code we
@@ -666,6 +682,7 @@ async def _exchange_code(self, code: str) -> Token:
666682
667683
Args:
668684
code: The authorization code we got from the callback.
685+
code_verifier: The PKCE code verifier to send, blank if unused.
669686
670687
Returns:
671688
A dict containing various tokens.
@@ -696,6 +713,8 @@ async def _exchange_code(self, code: str) -> Token:
696713
"code": code,
697714
"redirect_uri": self._callback_url,
698715
}
716+
if code_verifier:
717+
args["code_verifier"] = code_verifier
699718
body = urlencode(args, True)
700719

701720
# Fill the body/headers with credentials
@@ -914,11 +933,14 @@ async def handle_redirect_request(
914933
- ``scope``: the list of scopes set in ``oidc_config.scopes``
915934
- ``state``: a random string
916935
- ``nonce``: a random string
936+
- ``code_challenge``: a RFC7636 code challenge (if PKCE is supported)
917937
918-
In addition generating a redirect URL, we are setting a cookie with
919-
a signed macaroon token containing the state, the nonce and the
920-
client_redirect_url params. Those are then checked when the client
921-
comes back from the provider.
938+
In addition to generating a redirect URL, we are setting a cookie with
939+
a signed macaroon token containing the state, the nonce, the
940+
client_redirect_url, and (optionally) the code_verifier params. The state,
941+
nonce, and client_redirect_url are then checked when the client comes back
942+
from the provider. The code_verifier is passed back to the server during
943+
the token exchange and compared to the code_challenge sent in this request.
922944
923945
Args:
924946
request: the incoming request from the browser.
@@ -935,17 +957,33 @@ async def handle_redirect_request(
935957

936958
state = generate_token()
937959
nonce = generate_token()
960+
code_verifier = ""
938961

939962
if not client_redirect_url:
940963
client_redirect_url = b""
941964

965+
metadata = await self.load_metadata()
966+
967+
# Automatically enable PKCE if it is supported.
968+
extra_grant_values = {}
969+
if metadata.get("code_challenge_methods_supported"):
970+
code_verifier = generate_token(48)
971+
972+
# Note that we verified the server supports S256 earlier (in
973+
# OidcProvider._validate_metadata).
974+
extra_grant_values = {
975+
"code_challenge_method": "S256",
976+
"code_challenge": create_s256_code_challenge(code_verifier),
977+
}
978+
942979
cookie = self._macaroon_generaton.generate_oidc_session_token(
943980
state=state,
944981
session_data=OidcSessionData(
945982
idp_id=self.idp_id,
946983
nonce=nonce,
947984
client_redirect_url=client_redirect_url.decode(),
948985
ui_auth_session_id=ui_auth_session_id or "",
986+
code_verifier=code_verifier,
949987
),
950988
)
951989

@@ -966,7 +1004,6 @@ async def handle_redirect_request(
9661004
)
9671005
)
9681006

969-
metadata = await self.load_metadata()
9701007
authorization_endpoint = metadata.get("authorization_endpoint")
9711008
return prepare_grant_uri(
9721009
authorization_endpoint,
@@ -976,6 +1013,7 @@ async def handle_redirect_request(
9761013
scope=self._scopes,
9771014
state=state,
9781015
nonce=nonce,
1016+
**extra_grant_values,
9791017
)
9801018

9811019
async def handle_oidc_callback(
@@ -1003,7 +1041,9 @@ async def handle_oidc_callback(
10031041
# Exchange the code with the provider
10041042
try:
10051043
logger.debug("Exchanging OAuth2 code for a token")
1006-
token = await self._exchange_code(code)
1044+
token = await self._exchange_code(
1045+
code, code_verifier=session_data.code_verifier
1046+
)
10071047
except OidcError as e:
10081048
logger.warning("Could not exchange OAuth2 code: %s", e)
10091049
self._sso_handler.render_error(request, e.error, e.error_description)

synapse/util/macaroons.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ class OidcSessionData:
110110
ui_auth_session_id: str
111111
"""The session ID of the ongoing UI Auth ("" if this is a login)"""
112112

113+
code_verifier: str
114+
"""The random string used in the RFC7636 code challenge ("" if PKCE is not being used)."""
115+
113116

114117
class MacaroonGenerator:
115118
def __init__(self, clock: Clock, location: str, secret_key: bytes):
@@ -187,6 +190,7 @@ def generate_oidc_session_token(
187190
macaroon.add_first_party_caveat(
188191
f"ui_auth_session_id = {session_data.ui_auth_session_id}"
189192
)
193+
macaroon.add_first_party_caveat(f"code_verifier = {session_data.code_verifier}")
190194
macaroon.add_first_party_caveat(f"time < {expiry}")
191195

192196
return macaroon.serialize()
@@ -278,6 +282,7 @@ def verify_oidc_session_token(self, session: bytes, state: str) -> OidcSessionDa
278282
v.satisfy_general(lambda c: c.startswith("idp_id = "))
279283
v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
280284
v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
285+
v.satisfy_general(lambda c: c.startswith("code_verifier = "))
281286
satisfy_expiry(v, self._clock.time_msec)
282287

283288
v.verify(macaroon, self._secret_key)
@@ -287,11 +292,13 @@ def verify_oidc_session_token(self, session: bytes, state: str) -> OidcSessionDa
287292
idp_id = get_value_from_macaroon(macaroon, "idp_id")
288293
client_redirect_url = get_value_from_macaroon(macaroon, "client_redirect_url")
289294
ui_auth_session_id = get_value_from_macaroon(macaroon, "ui_auth_session_id")
295+
code_verifier = get_value_from_macaroon(macaroon, "code_verifier")
290296
return OidcSessionData(
291297
nonce=nonce,
292298
idp_id=idp_id,
293299
client_redirect_url=client_redirect_url,
294300
ui_auth_session_id=ui_auth_session_id,
301+
code_verifier=code_verifier,
295302
)
296303

297304
def _generate_base_macaroon(self, type: MacaroonType) -> pymacaroons.Macaroon:

0 commit comments

Comments
 (0)