Skip to content
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
168 changes: 168 additions & 0 deletions openserverless/common/oidc_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# 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

91 changes: 91 additions & 0 deletions openserverless/common/sso_namespace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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 hashlib
import re

import openserverless.common.validation as validation


class SsoNamespaceMapper:

def __init__(self, environ):
self._environ = environ

def namespace_for(self, claims):
username_claim = self._environ.get("OIDC_USERNAME_CLAIM", "preferred_username")
external_username = claims[username_claim]

namespace_claim = self._environ.get("OIDC_NAMESPACE_CLAIM")
if namespace_claim and claims.get(namespace_claim):
namespace = claims[namespace_claim]
if validation.is_valid_username(namespace):
return namespace

if self._preserve_valid() and validation.is_valid_username(external_username):
return external_username

return self._normalized_namespace(external_username, claims)

def _normalized_namespace(self, external_username, claims):
base = re.sub(r"[^a-z0-9]", "", external_username.lower())
if len(base) < 5:
base = f"{base}user"

suffix = self._suffix(external_username, claims)
max_len = self._max_len()
prefix_len = max_len - len(suffix)
namespace = f"{base[:prefix_len]}{suffix}"

if validation.is_valid_username(namespace):
return namespace

# Defensive fallback for unusual inputs where the base becomes empty.
namespace = f"user{suffix}"
if validation.is_valid_username(namespace):
return namespace

raise ValueError("Unable to derive a valid namespace from SSO claims")

def _suffix(self, external_username, claims):
suffix_len = self._suffix_len()
source = "|".join(
[
claims.get("iss", ""),
claims.get("sub", ""),
external_username,
]
)
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:suffix_len]

def _preserve_valid(self):
value = self._environ.get("SSO_NAMESPACE_PRESERVE_VALID", "true")
return str(value).lower() in ["1", "true", "yes", "on"]

def _suffix_len(self):
try:
value = int(self._environ.get("SSO_NAMESPACE_HASH_LENGTH", 8))
except (TypeError, ValueError):
value = 8
return min(max(value, 6), 16)

def _max_len(self):
try:
value = int(self._environ.get("SSO_NAMESPACE_MAX_LENGTH", 61))
except (TypeError, ValueError):
value = 61
return min(max(value, 13), 61)
Loading
Loading