Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for signed JWT #5

Merged
merged 17 commits into from
Mar 18, 2024
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
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)
disrupted marked this conversation as resolved.
Show resolved Hide resolved
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-----
Loading
Loading