-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Support PKCE for OAuth 2.0 #14750
Support PKCE for OAuth 2.0 #14750
Changes from 4 commits
32475f0
4aad25e
5e910b8
1e82afd
45d3bba
1ebd224
8682a71
d504e47
08df5d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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` | ||
|
||
| 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. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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() | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| Returns: | ||
| A dict containing various tokens. | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
@@ -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 = "" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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... :'(
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| ), | ||
| ) | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -976,6 +1010,7 @@ async def handle_redirect_request( | |
| scope=self._scopes, | ||
| state=state, | ||
| nonce=nonce, | ||
| **extra_grant_values, | ||
| ) | ||
|
|
||
| async def handle_oidc_callback( | ||
|
|
@@ -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) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.