blob: f47206d19211681aadf8e90a58e29e8af0c1cf45 [file]
# 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