credentials do hlavicky + crypto + docu
This commit is contained in:
+10
-1
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -10,6 +10,7 @@ class Settings:
|
||||
tenant_id: str = os.getenv("MS365_TENANT_ID", "")
|
||||
client_id: str = os.getenv("MS365_CLIENT_ID", "")
|
||||
client_secret: str = os.getenv("MS365_CLIENT_SECRET", "")
|
||||
credential_encoding_secret: str = os.getenv("MS365_CREDENTIAL_ENCODING_SECRET", "")
|
||||
graph_base_url: str = os.getenv("MS365_GRAPH_BASE_URL", "https://graph.microsoft.com/v1.0")
|
||||
graph_scope: str = os.getenv("MS365_GRAPH_SCOPE", "https://graph.microsoft.com/.default")
|
||||
request_timeout_seconds: float = float(os.getenv("MS365_REQUEST_TIMEOUT_SECONDS", "30"))
|
||||
@@ -22,5 +23,13 @@ class Settings:
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.tenant_id and self.client_id and self.client_secret)
|
||||
|
||||
def with_credentials(self, tenant_id: str, client_id: str, client_secret: str) -> "Settings":
|
||||
return replace(
|
||||
self,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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"],
|
||||
)
|
||||
+10
-10
@@ -2,17 +2,17 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, Response, status
|
||||
|
||||
from .config import settings
|
||||
from .config import Settings
|
||||
from .credentials import get_request_settings
|
||||
from .graph_client import MicrosoftGraphClient
|
||||
from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest
|
||||
from .services import CalendarService, DriveService, GroupsService, MailService, UsersService
|
||||
|
||||
router = APIRouter(tags=["microsoft365"])
|
||||
graph_client = MicrosoftGraphClient(settings)
|
||||
|
||||
|
||||
def get_graph_client() -> MicrosoftGraphClient:
|
||||
return graph_client
|
||||
def get_graph_client(request_settings: Settings = Depends(get_request_settings)) -> MicrosoftGraphClient:
|
||||
return MicrosoftGraphClient(request_settings)
|
||||
|
||||
|
||||
def get_users_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> UsersService:
|
||||
@@ -36,13 +36,13 @@ def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client))
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def microsoft365_status() -> dict[str, Any]:
|
||||
def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]:
|
||||
return {
|
||||
"configured": settings.is_configured,
|
||||
"tenant_id": bool(settings.tenant_id),
|
||||
"client_id": bool(settings.client_id),
|
||||
"client_secret": bool(settings.client_secret),
|
||||
"graph_base_url": settings.graph_base_url,
|
||||
"configured": request_settings.is_configured,
|
||||
"tenant_id": bool(request_settings.tenant_id),
|
||||
"client_id": bool(request_settings.client_id),
|
||||
"client_secret": bool(request_settings.client_secret),
|
||||
"graph_base_url": request_settings.graph_base_url,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user