142 lines
4.9 KiB
Python
142 lines
4.9 KiB
Python
import base64
|
|
import binascii
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated
|
|
|
|
from cryptography.exceptions import InvalidTag
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
from fastapi import Header, HTTPException, status
|
|
|
|
from .config import Settings, settings
|
|
|
|
|
|
CREDENTIAL_VERSION = "v1"
|
|
_HKDF_SALT = b"microsoft-365-service.credentials.v1"
|
|
|
|
|
|
def _b64url_decode(value: str) -> bytes:
|
|
padding = "=" * (-len(value) % 4)
|
|
try:
|
|
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
|
except (binascii.Error, UnicodeEncodeError) as exc:
|
|
raise ValueError("invalid base64url") from exc
|
|
|
|
|
|
def _derive_key(shared_secret: str, header_name: str) -> bytes:
|
|
return HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=_HKDF_SALT,
|
|
info=header_name.encode("utf-8"),
|
|
).derive(shared_secret.encode("utf-8"))
|
|
|
|
|
|
def decode_credential_header(encoded_value: str, *, header_name: str, shared_secret: str) -> str:
|
|
parts = encoded_value.split(".")
|
|
if len(parts) != 3 or parts[0] != CREDENTIAL_VERSION:
|
|
raise ValueError("unsupported credential encoding version")
|
|
|
|
nonce = _b64url_decode(parts[1])
|
|
ciphertext = _b64url_decode(parts[2])
|
|
if len(nonce) != 12:
|
|
raise ValueError("invalid nonce length")
|
|
|
|
key = _derive_key(shared_secret, header_name)
|
|
aad = header_name.encode("utf-8")
|
|
plaintext = AESGCM(key).decrypt(nonce, ciphertext, aad)
|
|
payload = json.loads(plaintext.decode("utf-8"))
|
|
|
|
expires_at = payload.get("expires_at")
|
|
if expires_at:
|
|
expires_at_datetime = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
if expires_at_datetime <= datetime.now(timezone.utc):
|
|
raise ValueError("credential value is expired")
|
|
|
|
value = payload.get("value")
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError("credential payload must contain a non-empty value")
|
|
return value
|
|
|
|
|
|
async def get_request_settings(
|
|
tenant_id: Annotated[
|
|
str | None,
|
|
Header(
|
|
alias="X-MS365-Tenant-Id",
|
|
description="Encoded value of MS365_TENANT_ID, the Microsoft Entra tenant id. Format: v1.<base64url nonce>.<base64url ciphertext+tag>.",
|
|
),
|
|
] = None,
|
|
client_id: Annotated[
|
|
str | None,
|
|
Header(
|
|
alias="X-MS365-Client-Id",
|
|
description="Encoded value of MS365_CLIENT_ID, the Microsoft Entra application client id. Format: v1.<base64url nonce>.<base64url ciphertext+tag>.",
|
|
),
|
|
] = None,
|
|
client_secret: Annotated[
|
|
str | None,
|
|
Header(
|
|
alias="X-MS365-Client-Secret",
|
|
description="Encoded value of MS365_CLIENT_SECRET, the Microsoft Entra application client secret. Format: v1.<base64url nonce>.<base64url ciphertext+tag>.",
|
|
),
|
|
] = None,
|
|
credential_version: Annotated[
|
|
str | None,
|
|
Header(
|
|
alias="X-MS365-Credential-Version",
|
|
description="Credential encoding version. Currently supported value: v1.",
|
|
),
|
|
] = None,
|
|
) -> Settings:
|
|
header_values = {
|
|
"X-MS365-Tenant-Id": tenant_id,
|
|
"X-MS365-Client-Id": client_id,
|
|
"X-MS365-Client-Secret": client_secret,
|
|
}
|
|
provided = {name: value for name, value in header_values.items() if value}
|
|
if not provided:
|
|
return settings
|
|
|
|
missing = [name for name, value in header_values.items() if not value]
|
|
if missing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"message": "Incomplete Microsoft 365 credential headers.", "missing_headers": missing},
|
|
)
|
|
|
|
if credential_version != CREDENTIAL_VERSION:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"X-MS365-Credential-Version must be {CREDENTIAL_VERSION}.",
|
|
)
|
|
|
|
if not settings.credential_encoding_secret:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="MS365_CREDENTIAL_ENCODING_SECRET is not configured.",
|
|
)
|
|
|
|
try:
|
|
decoded = {
|
|
name: decode_credential_header(
|
|
value,
|
|
header_name=name,
|
|
shared_secret=settings.credential_encoding_secret,
|
|
)
|
|
for name, value in provided.items()
|
|
}
|
|
except (InvalidTag, ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Microsoft 365 credential headers are malformed or cannot be authenticated.",
|
|
) from exc
|
|
|
|
return settings.with_credentials(
|
|
tenant_id=decoded["X-MS365-Tenant-Id"],
|
|
client_id=decoded["X-MS365-Client-Id"],
|
|
client_secret=decoded["X-MS365-Client-Secret"],
|
|
)
|