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 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
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`, `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
clokep marked this conversation as resolved.
Show resolved Hide resolved
- ``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(

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 +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