Skip to content

Commit be9087c

Browse files
Fetch client cert (#216)
1 parent d8f3068 commit be9087c

1 file changed

Lines changed: 61 additions & 15 deletions

File tree

app/services/client_oauth.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import hmac
44
import logging
55
import ssl
6-
from typing import Any, Dict
6+
from typing import Any, Dict, List
77

88
import jwt
99
from cryptography import x509
@@ -13,7 +13,6 @@
1313

1414
from app.config import ConfigClientOAuth
1515
from app.models.ura import UraNumber
16-
from app.utils.certificates.utils import enforce_cert_newlines
1716

1817
SSL_CLIENT_CERT_HEADER_NAME = "x-forwarded-tls-client-cert" # "x-proxy-ssl_client_cert"
1918

@@ -34,14 +33,22 @@ def _create_ssl_context(self) -> ssl.SSLContext:
3433
"""
3534
Create an SSL context for mTLS connections to the JWKS endpoint.
3635
"""
37-
if self.config.mtls_cert is None or self.config.mtls_key is None or self.config.verify_ca is None:
38-
raise ValueError("mTLS certificate and key must be provided for Client OAuth2")
36+
if (
37+
self.config.mtls_cert is None
38+
or self.config.mtls_key is None
39+
or self.config.verify_ca is None
40+
):
41+
raise ValueError(
42+
"mTLS certificate and key must be provided for Client OAuth2"
43+
)
3944

4045
context = ssl.create_default_context()
4146
if isinstance(self.config.verify_ca, bool) and self.config.verify_ca is True:
4247
context.verify_mode = ssl.CERT_REQUIRED
4348

44-
context.load_cert_chain(certfile=self.config.mtls_cert, keyfile=self.config.mtls_key)
49+
context.load_cert_chain(
50+
certfile=self.config.mtls_cert, keyfile=self.config.mtls_key
51+
)
4552
if isinstance(self.config.verify_ca, str):
4653
context.load_verify_locations(cafile=self.config.verify_ca)
4754

@@ -65,7 +72,9 @@ def verify(self, request: Request) -> Dict[str, Any]:
6572
"""
6673
auth_header = request.headers.get("authorization")
6774
if not auth_header or not auth_header.lower().startswith("bearer "):
68-
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
75+
raise HTTPException(
76+
status_code=401, detail="Missing or invalid Authorization header"
77+
)
6978

7079
token = auth_header[7:] # Remove "Bearer "
7180
claims = self._verify_token(token)
@@ -77,7 +86,9 @@ def _verify_token(self, token: str) -> Dict[str, Any]:
7786
"""
7887
Verify JWT token and return claims.
7988
"""
80-
jwk_client = PyJWKClient(self.config.jwks_url, cache_keys=True, ssl_context=self._ssl_context)
89+
jwk_client = PyJWKClient(
90+
self.config.jwks_url, cache_keys=True, ssl_context=self._ssl_context
91+
)
8192
signing_key = jwk_client.get_signing_key_from_jwt(token).key
8293

8394
try:
@@ -105,16 +116,16 @@ def _verify_mtls(request: Request, claims: Dict[str, Any]) -> None:
105116
Verify mTLS binding by checking cnf.x5t#S256 against presented client certificate thumbprint.
106117
"""
107118

108-
# Extract presented client certificate thumbprint from request
109-
cert_pem = request.headers.get(SSL_CLIENT_CERT_HEADER_NAME)
110-
if not cert_pem:
119+
certs = ClientOAuthService.get_pem_from_request(request)
120+
if not certs:
111121
logger.error("Client certificate not presented or verification failed")
112-
raise HTTPException(status_code=401, detail="Client certificate not presented or verification failed")
113-
114-
cert_pem = enforce_cert_newlines(cert_pem)
122+
raise HTTPException(
123+
status_code=401,
124+
detail="Client certificate not presented or verification failed",
125+
)
115126

116-
# Calculate thumbprint of presented certificate
117-
presented_cert = x509.load_pem_x509_certificate(cert_pem.encode())
127+
# Calculate thumbprint of presented client certificate, ignoring the chain
128+
presented_cert = x509.load_pem_x509_certificate(certs[0].encode())
118129
cert_der = presented_cert.public_bytes(serialization.Encoding.DER)
119130
sha256_hash = hashlib.sha256(cert_der).digest()
120131
request_cert_fp = base64.urlsafe_b64encode(sha256_hash).rstrip(b"=").decode()
@@ -135,3 +146,38 @@ def _verify_mtls(request: Request, claims: Dict[str, Any]) -> None:
135146
if not hmac.compare_digest(fp, request_cert_fp):
136147
logger.debug("mTLS binding failed")
137148
raise HTTPException(status_code=401, detail="Invalid token")
149+
150+
@staticmethod
151+
def get_pem_from_request(request: Request) -> List[str]:
152+
"""
153+
Extracts and returns the PEM-encoded client certificate from the request headers.
154+
"""
155+
if (
156+
SSL_CLIENT_CERT_HEADER_NAME not in request.headers
157+
or not request.headers.get(SSL_CLIENT_CERT_HEADER_NAME)
158+
):
159+
logger.debug("Client certificate not found or verification failed.")
160+
return []
161+
162+
certs = request.headers.get(SSL_CLIENT_CERT_HEADER_NAME, "").split(",")
163+
return [
164+
ClientOAuthService.fixup_cert_headers_and_footers(cert) for cert in certs
165+
]
166+
167+
@staticmethod
168+
def fixup_cert_headers_and_footers(cert: str) -> str:
169+
# Add PEM headers/footers if missing
170+
if not cert.startswith("-----BEGIN CERTIFICATE-----"):
171+
cert = (
172+
"-----BEGIN CERTIFICATE-----\n" + cert + "\n-----END CERTIFICATE-----"
173+
)
174+
175+
# If we are by any chance missing newlines after/before the headers, add them
176+
if not cert.startswith("-----BEGIN CERTIFICATE-----\n"):
177+
cert = cert.replace(
178+
"-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n"
179+
)
180+
cert = cert.replace(
181+
"-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----"
182+
)
183+
return cert

0 commit comments

Comments
 (0)