-
-
Notifications
You must be signed in to change notification settings - Fork 575
fix(oidc): simplify refresh identity validation #1905
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -21,10 +21,13 @@ | |
| from social_core.exceptions import ( | ||
| AuthInvalidParameter, | ||
| AuthMissingParameter, | ||
| AuthReauthenticationRequired, | ||
| AuthTokenError, | ||
| ) | ||
| from social_core.utils import cache | ||
|
|
||
| _ID_TOKEN_CONTEXT_KEY = "_oidc_id_token_context" | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Mapping | ||
|
|
||
|
|
@@ -78,7 +81,6 @@ class OpenIdConnectAuth(BaseOAuth2PKCE): | |
| JWT_LEEWAY: float = 1.0 # seconds | ||
| VALIDATE_AT_HASH: bool = True | ||
| CUSTOM_AT_HASH_ALGO: str | None = None | ||
| ID_TOKEN_CONTEXT_KEY = "_oidc_id_token_context" | ||
| # When these options are unspecified, server will choose via openid autoconfiguration | ||
| ID_TOKEN_ISSUER = "" | ||
| ACCESS_TOKEN_URL = "" | ||
|
|
@@ -282,10 +284,6 @@ def validate_claims(self, id_token) -> None: | |
| else: | ||
| raise AuthTokenError(self, "Incorrect id_token: nonce") | ||
|
|
||
| def validate_refresh_claims(self, id_token) -> None: | ||
| """Validate claims that do not depend on the authentication request.""" | ||
| self.validate_temporal_claims(id_token) | ||
|
|
||
| def find_valid_key(self, id_token): | ||
| kid = jwt.get_unverified_header(id_token).get("kid") | ||
|
|
||
|
|
@@ -366,14 +364,16 @@ def validate_and_return_id_token(self, id_token, access_token): | |
| http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation. | ||
| """ | ||
| claims = self.decode_and_validate_id_token(id_token, access_token) | ||
| self.validate_required_id_token_claims(claims) | ||
| self.validate_claims(claims) | ||
|
|
||
| return claims | ||
|
|
||
| def validate_and_return_refresh_id_token(self, id_token, access_token): | ||
| """Validate an ID token returned by a refresh request.""" | ||
| claims = self.decode_and_validate_id_token(id_token, access_token) | ||
| self.validate_refresh_claims(claims) | ||
| self.validate_required_id_token_claims(claims) | ||
| self.validate_temporal_claims(claims) | ||
|
Comment on lines
+375
to
+376
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.
The 5.1.0 base class exposed Useful? React with 👍 / 👎.
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. I believe that the intermediate |
||
|
|
||
| return claims | ||
|
|
||
|
|
@@ -441,90 +441,52 @@ def id_token_audiences(audience) -> set[str]: | |
|
|
||
| def validate_authorized_party(self, claims, client_id: str) -> None: | ||
| """Validate the client authorized to use the ID token.""" | ||
| audience = claims.get("aud") | ||
| try: | ||
| self.id_token_audiences(audience) | ||
| except ValueError as error: | ||
| raise AuthTokenError(self, "Incorrect id_token: aud") from error | ||
|
|
||
| has_authorized_party = "azp" in claims | ||
| authorized_party = claims.get("azp") | ||
| if ( | ||
| isinstance(audience, list) | ||
| and len(audience) > 1 | ||
| and not has_authorized_party | ||
| ) or (has_authorized_party and authorized_party != client_id): | ||
| if "azp" in claims and claims["azp"] != client_id: | ||
| raise AuthTokenError(self, "Incorrect id_token: azp") | ||
|
Comment on lines
+444
to
445
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.
When Useful? React with 👍 / 👎. |
||
|
|
||
| def id_token_context(self, claims) -> dict[str, Any]: | ||
| """Return identity claims that must remain stable across refreshes.""" | ||
| context = {} | ||
| for claim in ("iss", "sub", "aud"): | ||
| def validate_required_id_token_claims(self, claims) -> None: | ||
| """Validate claims required in every ID token.""" | ||
| for claim in ("iss", "sub", "aud", "exp"): | ||
| if claim not in claims: | ||
| raise AuthTokenError(self, f"Incorrect id_token: {claim}") | ||
| context[claim] = claims[claim] | ||
|
|
||
| for claim in ("auth_time", "nonce", "azp"): | ||
| @staticmethod | ||
| def _id_token_context(claims) -> dict[str, Any]: | ||
| """Return original claims used to validate refresh continuity.""" | ||
| context = {claim: claims[claim] for claim in ("iss", "sub", "aud")} | ||
| for claim in ("auth_time", "nonce"): | ||
| if claim in claims: | ||
| context[claim] = claims[claim] | ||
| return context | ||
|
|
||
| def validate_id_token_context(self, previous, current) -> None: | ||
| def validate_refresh_id_token_claims(self, previous, current) -> None: | ||
| """Validate identity continuity for an ID token refresh.""" | ||
| if not isinstance(previous, dict): | ||
| raise AuthTokenError(self, "Invalid stored OpenID Connect context") | ||
|
|
||
| for claim in ("iss", "sub", "aud"): | ||
| if claim not in previous: | ||
| raise AuthTokenError(self, "Invalid stored OpenID Connect context") | ||
| if not isinstance(previous, dict) or any( | ||
| claim not in previous for claim in ("iss", "sub", "aud") | ||
| ): | ||
| raise AuthReauthenticationRequired(self) | ||
|
|
||
| for claim in ("iss", "sub"): | ||
| if previous[claim] != current[claim]: | ||
| raise AuthTokenError(self, f"Incorrect refreshed id_token: {claim}") | ||
|
|
||
| self.validate_id_token_audience_context(previous, current) | ||
|
|
||
| # OIDC Core 1.0 section 12.2 requires exact azp continuity, | ||
| # including whether the claim is present. | ||
| if previous.get("azp") != current.get("azp"): | ||
| raise AuthTokenError(self, "Incorrect refreshed id_token: azp") | ||
|
|
||
| for claim in ("auth_time", "nonce"): | ||
| if claim in current and previous.get(claim) != current[claim]: | ||
| raise AuthTokenError( | ||
| self, | ||
| f"Incorrect refreshed id_token: {claim}", | ||
| ) | ||
|
|
||
| def validate_id_token_audience_context(self, previous, current) -> None: | ||
| """Validate that refreshed ID token audiences are unchanged.""" | ||
| try: | ||
| previous_audiences = self.id_token_audiences(previous["aud"]) | ||
| except ValueError as error: | ||
| raise AuthTokenError( | ||
| self, "Invalid stored OpenID Connect context" | ||
| ) from error | ||
| raise AuthReauthenticationRequired(self) from error | ||
| try: | ||
| current_audiences = self.id_token_audiences(current["aud"]) | ||
| except ValueError as error: | ||
| raise AuthTokenError(self, "Incorrect id_token: aud") from error | ||
| if previous_audiences != current_audiences: | ||
| raise AuthTokenError(self, "Incorrect refreshed id_token: aud") | ||
|
|
||
| def validate_legacy_id_token_context(self, uid: str, current) -> None: | ||
| """Bind a legacy association to a refreshed ID token when possible.""" | ||
| # ID_KEY alone cannot prove how a subclass derived its persisted UID. | ||
| if ( | ||
| self.id_key() != "sub" | ||
| or type(self).get_user_id is not OpenIdConnectAuth.get_user_id | ||
| ): | ||
| raise AuthTokenError( | ||
| self, | ||
| "OpenID Connect identity context is unavailable; " | ||
| "reauthentication required", | ||
| ) | ||
| if uid != current["sub"]: | ||
| raise AuthTokenError(self, "Incorrect refreshed id_token: sub") | ||
| for claim in ("auth_time", "nonce"): | ||
| if claim in current and previous.get(claim) != current[claim]: | ||
| raise AuthTokenError( | ||
| self, | ||
| f"Incorrect refreshed id_token: {claim}", | ||
| ) | ||
|
|
||
| def extra_data( | ||
| self, | ||
|
|
@@ -535,21 +497,35 @@ def extra_data( | |
| pipeline_kwargs: dict[str, Any], | ||
| ) -> dict[str, Any]: | ||
| data = super().extra_data(user, uid, response, details, pipeline_kwargs) | ||
| previous_context = details.get(self.ID_TOKEN_CONTEXT_KEY) | ||
|
|
||
| if response.get("id_token") is not None: | ||
| if self.id_token is None: | ||
| raise AuthTokenError(self, "ID token was not validated") | ||
| current_context = self.id_token_context(self.id_token) | ||
| if previous_context is not None: | ||
| self.validate_id_token_context(previous_context, current_context) | ||
| data[self.ID_TOKEN_CONTEXT_KEY] = previous_context | ||
| else: | ||
| if not pipeline_kwargs: | ||
| self.validate_legacy_id_token_context(uid, current_context) | ||
| data[self.ID_TOKEN_CONTEXT_KEY] = current_context | ||
| elif previous_context is not None: | ||
| data[self.ID_TOKEN_CONTEXT_KEY] = previous_context | ||
| response_id_token = response.get("id_token") | ||
| if response_id_token is not None: | ||
| data["id_token"] = response_id_token | ||
| elif "id_token" in details: | ||
| data["id_token"] = details["id_token"] | ||
|
|
||
| previous_context = details.get(_ID_TOKEN_CONTEXT_KEY) | ||
| if pipeline_kwargs: | ||
| if response_id_token is not None: | ||
| if self.id_token is None: | ||
| raise AuthTokenError(self, "ID token was not validated") | ||
| data[_ID_TOKEN_CONTEXT_KEY] = self._id_token_context(self.id_token) | ||
| return data | ||
|
|
||
| if previous_context is not None: | ||
| data[_ID_TOKEN_CONTEXT_KEY] = previous_context | ||
| if response_id_token is None: | ||
| return data | ||
| if self.id_token is None: | ||
| raise AuthTokenError(self, "ID token was not validated") | ||
|
|
||
| if previous_context is None: | ||
| # Legacy associations have no original claim context. Their refreshes | ||
| # were historically not continuity-checked, so establish the baseline | ||
| # from this fully validated refresh token and enforce it thereafter. | ||
| previous_context = self._id_token_context(self.id_token) | ||
|
Comment on lines
+521
to
+525
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.
For any association created before 5.1, Useful? React with 👍 / 👎.
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. The protection was non-existing before. This starts to protect new refreshes. To keep the code limited, the legacy context is not migrated. |
||
| else: | ||
| self.validate_refresh_id_token_claims(previous_context, self.id_token) | ||
| data[_ID_TOKEN_CONTEXT_KEY] = previous_context | ||
|
|
||
| return data | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replacing
OpenIdConnectAuth.ID_TOKEN_CONTEXT_KEY, which shipped in 5.1.0 and was accessed through backend instances, with a private module constant breaks integrations that reference the attribute and silently ignores subclasses that override it to control the storage key. Preserve the class attribute and have the implementation use it so upgrades do not raiseAttributeErroror leave previously customized contexts unread.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having it on class gave false impression that this is something to override, what it is not.