Skip to content

Commit

Permalink
Add support for signed JWT (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
torbsto and disrupted authored Mar 18, 2024
1 parent dce6b4f commit 7b028b2
Show file tree
Hide file tree
Showing 10 changed files with 2,469 additions and 7 deletions.
37 changes: 36 additions & 1 deletion keycloak_oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from pathlib import Path
import ssl
from typing import Any
import pydantic
from authlib.common.security import generate_token
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
from authlib.jose import JWTClaims, JsonWebToken, JsonWebKey
from authlib.oauth2.rfc7523 import PrivateKeyJWT

from starlette import status
from starlette.datastructures import URL
Expand All @@ -23,7 +26,7 @@ class KeycloakOAuth2:
def __init__(
self,
client_id: str,
client_secret: str,
client_secret: str | bytes | None,
server_metadata_url: str,
client_kwargs: dict[str, Any],
base_url: str = "/",
Expand All @@ -32,7 +35,14 @@ def __init__(
self.code_verifier = generate_token(48)
self._base_url = base_url
self._logout_page = logout_target

oauth = OAuth()

# HACK: load custom certificate including default certifi cacert chain
if verify := client_kwargs.get("verify"):
ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23, verify=verify)
client_kwargs["verify"] = ssl_context

oauth.register(
name="keycloak",
# client_id and client_secret are created in keycloak
Expand All @@ -42,9 +52,30 @@ def __init__(
client_kwargs=client_kwargs,
code_challenge_method="S256",
)

assert isinstance(oauth.keycloak, StarletteOAuth2App)
self.keycloak = oauth.keycloak

async def setup_signed_jwt(self, keypair: Path, public_key: Path) -> None:
"""Setup client authentication for signed JWT.
:param keypair: Path to keypair.pem, generated via `openssl genrsa - out keypair.pem 2048`
:param public_key: Path to publickey.crt, generated via `openssl rsa -in keypair.pem -pubout -out publickey.crt`
"""
self.keycloak.client_secret = keypair.read_bytes()
self.pub = JsonWebKey.import_key(
public_key.read_text(), {"kty": "RSA", "use": "sig"}
).as_dict()

metadata = await self.keycloak.load_server_metadata()
auth_method = PrivateKeyJWT(metadata["token_endpoint"])
self.keycloak.client_auth_methods = [auth_method]
self.keycloak.client_kwargs.update(
{
"token_endpoint_auth_method": auth_method.name,
}
)

def setup_fastapi_routes(self) -> None:
"""Create FastAPI router and register API endpoints."""
import fastapi
Expand All @@ -53,6 +84,10 @@ def setup_fastapi_routes(self) -> None:
self.router.add_api_route("/login", self.login_page)
self.router.add_api_route("/callback", self.auth)
self.router.add_api_route("/logout", self.logout)
self.router.add_api_route("/certs", self.public_keys)

async def public_keys(self, request: Request) -> dict[str, Any]:
return {"keys": [self.pub]}

async def login_page(
self, request: Request, redirect_target: str | None = None
Expand Down
14 changes: 13 additions & 1 deletion keycloak_oauth/starlette_admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Sequence
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import RedirectResponse
from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Route
from starlette_admin.auth import AdminUser, AuthProvider, login_not_required
from starlette_admin.base import BaseAdmin
Expand Down Expand Up @@ -50,6 +50,11 @@ async def render_logout(
async def handle_auth_callback(self, request: Request) -> RedirectResponse:
return await self.keycloak.auth(request)

@login_not_required
async def public_keys(self, request: Request) -> JSONResponse:
keys = await self.keycloak.public_keys(request)
return JSONResponse(keys)

def setup_admin(self, admin: BaseAdmin) -> None:
super().setup_admin(admin)
"""add custom authentication callback route"""
Expand All @@ -61,3 +66,10 @@ def setup_admin(self, admin: BaseAdmin) -> None:
name="authorize_keycloak",
)
)
admin.routes.append(
Route(
"/auth/certs",
self.public_keys,
methods=["GET"],
)
)
22 changes: 20 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pyright = "^1.1.351"

[tool.poetry.group.test.dependencies]
pytest = "^8.0.1"
pytest-asyncio = "^0.23.5.post1"
pytest-mock = "^3.12.0"
python-keycloak = "^3.9.0"
testcontainers-keycloak = { git = "https://github.com/TheForgottened/testcontainers-python", subdirectory = "keycloak" } # updated Keycloak container: https://github.com/testcontainers/testcontainers-python/pull/369
Expand Down
28 changes: 28 additions & 0 deletions tests/resources/keycloak/keypair.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCw0/V2NNevVeIY
Id+DPp+R9+yNRb3+gXuTSyrnt1Z8udvmJs5IBbjZvJNhPt3+tmp9u7ibkiKYSx/7
CRr70po06pmNAYQCUmxIo5xbOivXcgMYOYzMLA94qX7UrYUjcWl7uda2i6f3V0Y2
P4SRADSP6vYEKE4iSuOh9VlpuNY85q/pZPbn9ZtIyDWT5DqUr1Y8Ia2i6oiHWmCM
1f5czitGtwrid1EnEYWb4QXFhTws9y9MmbrAzE+7NnJFi3f3xzQXGP+xIdHKpnho
kCpHSNkI4s5gB/fU+MTyBCWdh6TdXD64mpBIV/egQ2ok/9k3o9C8MA4MSovvFFrk
jBL5bmkVAgMBAAECggEAEOw4EZf9DEqr2KNlQKo9mzqe6OZDyJebu/z1njdtj4I6
CUytcTca+buSXzwlArtydZYBlOHnbavC48N7UZ5WI7pP966tc4tv0YPW8uQeTgAb
S7Y2Q1P0JxgRi0kP9NRlw/GFGvNCn+k6TvbFORtL7HoQEVXKJH4GSvmwFO4bkrhY
L1qPAIMMoNKcAuMf426t9e1YWHWYz8ELGBH/nx+UJX2LU0SS1AjdAlyvROsBPGWt
/E9hgXe/sbPZ002C9phhl66NXAwCqkW6yA/jT8G0dVJbDLc0PR9HtZyL1zlyWpDU
i4l/1HYM9qgejxxQgoF/lXIARGWtwKok30QyYiq2AQKBgQDrReufZtYCF/1G24Jn
TIEdVjRH3VrZar2OOA9E/ju6vN1fzoP9qv4SMz8PZZhfREehX7vAQkRPVhx32fAB
+ThGACzcW13Crx3zvDXsqsh1VRlXnNKcRKB2ljGuLJ4JeTG5Kf3HI7aVvw7hiIhX
A+Oo7b93t9vlMVSBIKp9LnZbqwKBgQDAZ+71vxSc82+iFnVNyjK8oOT6bsnBLdfO
saEvhxgvBoQVfmdSevIaIlwErThd6bWpRcp5znrklKFYK07MB+QlmOAtWfRwKEB5
qU4FdmtRyGgkg8T9/9/yE8/LiDT4nYFqRtsEAHjVmx7FDQCqvXbtZonSzaivoAoS
2bcRnV+OPwKBgQC3j2nyiA1YvNbDPehUKABknylTGIUVNI6IM3zWW3TGkSw2361j
cNGh6ZG9tYpYabFpWoPl0M3zCEBV0hfLsmIRW3mkwzQ3/ODllWaNLAISaT7IeHZp
rbF0VGKWfgEfaws8aGKzyE1gMBywIhIdsc8hsby87xoFi6Ney9m4qVN22QKBgQCj
eetDq3WVIRURb+l9DbZsJHxI98a+NvgsqynbmvoGQpAJPxwErWd0owryAkdpK8Bo
sV6mfbRW8J3hrvJFUtMayrh2b/7LKLgXZq1e4M2wcAlkNP00HqqlIQYl1XXEYvbp
WIiP7uK8Aw9yt2iAqXgZn0ys6oZPqjfE6mysL71XuwKBgH/Lc/QzN0hqRLX/Qgy8
l2//pC2zosOSd3tzXPEbzrRc/xbD9KJECnSeQ1lyrSAzV1fuIoZ5q314aSIgnsO0
IVVBPewRz83rycTGmThDDYp0TcSv/gwzY2prRkWETDoBZ+LPyrDtfsjOYTMjm1YP
baxbfQ3LrLw7ilH6Vrj35a78
-----END PRIVATE KEY-----
9 changes: 9 additions & 0 deletions tests/resources/keycloak/publickey.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsNP1djTXr1XiGCHfgz6f
kffsjUW9/oF7k0sq57dWfLnb5ibOSAW42byTYT7d/rZqfbu4m5IimEsf+wka+9Ka
NOqZjQGEAlJsSKOcWzor13IDGDmMzCwPeKl+1K2FI3Fpe7nWtoun91dGNj+EkQA0
j+r2BChOIkrjofVZabjWPOav6WT25/WbSMg1k+Q6lK9WPCGtouqIh1pgjNX+XM4r
RrcK4ndRJxGFm+EFxYU8LPcvTJm6wMxPuzZyRYt398c0Fxj/sSHRyqZ4aJAqR0jZ
COLOYAf31PjE8gQlnYek3Vw+uJqQSFf3oENqJP/ZN6PQvDAODEqL7xRa5IwS+W5p
FQIDAQAB
-----END PUBLIC KEY-----
File renamed without changes.
Loading

0 comments on commit 7b028b2

Please sign in to comment.