| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| # | |
| import base64 | |
| import json | |
| import time | |
| import requests | |
| from cryptography.hazmat.primitives import hashes | |
| from cryptography.hazmat.primitives.asymmetric import padding, rsa | |
| class OidcValidationError(Exception): | |
| pass | |
| class OidcForbiddenError(Exception): | |
| pass | |
| def _b64url_decode(value): | |
| value += "=" * (-len(value) % 4) | |
| return base64.urlsafe_b64decode(value.encode("ascii")) | |
| def _json_b64url_decode(value): | |
| return json.loads(_b64url_decode(value)) | |
| def _int_b64url_decode(value): | |
| return int.from_bytes(_b64url_decode(value), byteorder="big") | |
| class OidcTokenValidator: | |
| def __init__(self, environ, jwks=None, now=None): | |
| self._environ = environ | |
| self._jwks = jwks | |
| self._now = now | |
| def _get_required(self, key): | |
| value = self._environ.get(key) | |
| if not value: | |
| raise OidcValidationError(f"missing OIDC configuration: {key}") | |
| return value | |
| def _get_jwks(self): | |
| if self._jwks is not None: | |
| return self._jwks | |
| jwks_url = self._get_required("OIDC_JWKS_URL") | |
| response = requests.get(jwks_url, timeout=10) | |
| response.raise_for_status() | |
| self._jwks = response.json() | |
| return self._jwks | |
| def _find_jwk(self, kid): | |
| for jwk in self._get_jwks().get("keys", []): | |
| if jwk.get("kid") == kid: | |
| return jwk | |
| raise OidcValidationError("no matching JWK found for token kid") | |
| def _public_key(self, jwk): | |
| if jwk.get("kty") != "RSA": | |
| raise OidcValidationError("unsupported JWK key type") | |
| public_numbers = rsa.RSAPublicNumbers( | |
| e=_int_b64url_decode(jwk["e"]), | |
| n=_int_b64url_decode(jwk["n"]), | |
| ) | |
| return public_numbers.public_key() | |
| def _verify_signature(self, token, header): | |
| parts = token.split(".") | |
| signed_data = f"{parts[0]}.{parts[1]}".encode("ascii") | |
| signature = _b64url_decode(parts[2]) | |
| jwk = self._find_jwk(header.get("kid")) | |
| public_key = self._public_key(jwk) | |
| try: | |
| public_key.verify( | |
| signature, | |
| signed_data, | |
| padding.PKCS1v15(), | |
| hashes.SHA256(), | |
| ) | |
| except Exception as exc: | |
| raise OidcValidationError("invalid token signature") from exc | |
| def _validate_time_claims(self, claims): | |
| now = self._now if self._now is not None else int(time.time()) | |
| leeway = int(self._environ.get("OIDC_CLOCK_LEEWAY_SECONDS", "30")) | |
| exp = claims.get("exp") | |
| if exp is None or int(exp) < now - leeway: | |
| raise OidcValidationError("token expired") | |
| nbf = claims.get("nbf") | |
| if nbf is not None and int(nbf) > now + leeway: | |
| raise OidcValidationError("token is not valid yet") | |
| def _validate_issuer(self, claims): | |
| expected = self._get_required("OIDC_ISSUER_URL") | |
| if claims.get("iss") != expected: | |
| raise OidcValidationError("invalid issuer") | |
| def _validate_audience(self, claims): | |
| expected = self._get_required("OIDC_AUDIENCE") | |
| audience = claims.get("aud") | |
| if isinstance(audience, list) and expected in audience: | |
| return | |
| if audience == expected: | |
| return | |
| raise OidcValidationError("invalid audience") | |
| def _validate_required_group(self, claims): | |
| required_group = self._environ.get("OIDC_REQUIRED_GROUP") | |
| if not required_group: | |
| return | |
| groups_claim = self._environ.get("OIDC_GROUPS_CLAIM", "groups") | |
| groups = claims.get(groups_claim, []) | |
| if isinstance(groups, str): | |
| groups = [groups] | |
| if required_group not in groups: | |
| raise OidcForbiddenError("missing required group") | |
| def validate(self, token): | |
| if not token: | |
| raise OidcValidationError("missing access token") | |
| parts = token.split(".") | |
| if len(parts) != 3: | |
| raise OidcValidationError("invalid token format") | |
| header = _json_b64url_decode(parts[0]) | |
| claims = _json_b64url_decode(parts[1]) | |
| if header.get("alg") != "RS256": | |
| raise OidcValidationError("unsupported token algorithm") | |
| self._verify_signature(token, header) | |
| self._validate_issuer(claims) | |
| self._validate_audience(claims) | |
| self._validate_time_claims(claims) | |
| self._validate_required_group(claims) | |
| username_claim = self._environ.get("OIDC_USERNAME_CLAIM", "preferred_username") | |
| if not claims.get(username_claim): | |
| raise OidcValidationError(f"missing username claim: {username_claim}") | |
| return claims |