Skip to content
Closed
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
10 changes: 10 additions & 0 deletions custom_components/reflex_clerk_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
sign_up_button,
)
from .user_components import user_button, user_profile
from .organization_components import (
create_organization,
organization_profile,
organization_switcher,
organization_list,
)

__all__ = [
"ClerkState",
Expand All @@ -35,7 +41,11 @@
"clerk_loaded",
"clerk_loading",
"clerk_provider",
"create_organization",
"on_load",
"organization_list",
"organization_profile",
"organization_switcher",
"protect",
"redirect_to_user_profile",
"register_on_auth_change_handler",
Expand Down
71 changes: 55 additions & 16 deletions custom_components/reflex_clerk_api/clerk_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class ClerkState(rx.State):
"nbf": {"essential": True},
# "azp": {"essential": False, "values": ["http://localhost:3000", "https://example.com"]},
}
_jwt_validate_leeway_seconds: ClassVar[int] = 60
"""Clock-skew leeway (seconds) for validating JWT claims like exp/nbf."""

@classmethod
def register_dependent_handler(cls, handler: EventCallback) -> None:
Expand All @@ -84,6 +86,15 @@ def set_claims_options(cls, claims_options: dict[str, Any]) -> None:
"""Set the claims options for the JWT claims validation."""
cls._claims_options = claims_options

@classmethod
def set_jwt_validate_leeway_seconds(cls, seconds: int) -> None:
"""Set clock-skew leeway (seconds) for JWT exp/nbf validation.

Default is 60 seconds. Increase if you see intermittent ExpiredTokenError
due to clock drift between Clerk servers and your backend.
"""
cls._jwt_validate_leeway_seconds = seconds

@property
def client(self) -> clerk_backend_api.Clerk:
if self._client is None:
Expand Down Expand Up @@ -115,9 +126,13 @@ async def set_clerk_session(self, token: str) -> EventType:
return ClerkState.clear_clerk_session
try:
# Validate the token according to the claim options (e.g. iss, exp, nbf, azp.)
decoded.validate()
except (jose_errors.InvalidClaimError, jose_errors.MissingClaimError) as e:
logging.warning(f"JWT token is invalid: {e}")
decoded.validate(leeway=self._jwt_validate_leeway_seconds)
except (
jose_errors.ExpiredTokenError,
jose_errors.InvalidClaimError,
jose_errors.MissingClaimError,
) as e:
logging.warning(f"JWT token validation failed: {type(e).__name__}: {e}")
return ClerkState.clear_clerk_session

async with self:
Expand Down Expand Up @@ -300,9 +315,9 @@ def add_imports(
) -> rx.ImportDict:
addl_imports: rx.ImportDict = {
"@clerk/clerk-react": ["useAuth"],
"react": ["useContext", "useEffect"],
"/utils/context": ["EventLoopContext"],
"/utils/state": ["Event"],
"react": ["useContext", "useEffect", "useRef"],
"$/utils/context": ["EventLoopContext"],
"$/utils/state": ["ReflexEvent"],
}
return addl_imports

Expand All @@ -313,26 +328,50 @@ def add_custom_code(self) -> list[str]:
"""
function ClerkSessionSynchronizer({ children }) {
const { getToken, isLoaded, isSignedIn } = useAuth()
const [ addEvents, connectErrors ] = useContext(EventLoopContext)
const [ addEvents ] = useContext(EventLoopContext)
const lastSentRef = useRef({ stateKey: null, addEvents: null })

useEffect(() => {
if (isLoaded && !!addEvents) {
if (isSignedIn) {
getToken().then(token => {
addEvents([Event("%s.set_clerk_session", {token})])
// Wait for all dependencies to be ready.
if (!isLoaded || !addEvents) return

// Deduplicate rapid calls, but remain reconnect-safe:
// addEvents identity changes across websocket reconnects, so include it in the key.
const stateKey = isSignedIn ? "signed_in" : "signed_out"
if (
lastSentRef.current?.stateKey === stateKey &&
lastSentRef.current?.addEvents === addEvents
) return
lastSentRef.current = { stateKey, addEvents }

if (isSignedIn) {
// Prefer a fresh token; cached tokens can be close to expiry.
// If this Clerk version doesn't support skipCache, fall back to the default call.
Promise.resolve()
.then(() => getToken({ skipCache: true }))
.catch(() => getToken())
.then(token => {
if (token) {
addEvents([ReflexEvent("%s.set_clerk_session", {token})])
} else {
// Token unavailable despite isSignedIn - clear to avoid stuck auth state.
addEvents([ReflexEvent("%s.clear_clerk_session")])
}
}).catch(() => {
// Token retrieval failed - clear to avoid stuck auth state.
addEvents([ReflexEvent("%s.clear_clerk_session")])
})
} else {
addEvents([Event("%s.clear_clerk_session")])
}
} else {
addEvents([ReflexEvent("%s.clear_clerk_session")])
}
}, [isSignedIn])
}, [isLoaded, isSignedIn, addEvents, getToken])

return (
<>{children}</>
)
}
"""
% (clerk_state_name, clerk_state_name)
% (clerk_state_name, clerk_state_name, clerk_state_name, clerk_state_name)
]


Expand Down
162 changes: 162 additions & 0 deletions custom_components/reflex_clerk_api/organization_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import reflex as rx

from reflex_clerk_api.base import ClerkBase
from reflex_clerk_api.models import Appearance


class CreateOrganization(ClerkBase):
"""
The CreateOrganization component provides a form for users to create new organizations.

This component renders Clerk's <CreateOrganization /> React component,
allowing users to set up new organizations with customizable appearance and routing.
"""

tag = "CreateOrganization"

appearance: Appearance | None = None
"Optional object to style your components. Will only affect Clerk components."

path: str | None = None
"The path where the component is mounted when routing is set to 'path'."

routing: str | None = None
"The routing strategy for your pages. Defaults to 'path' for frameworks that handle routing, or 'hash' for other SDKs."

after_create_organization_url: str | None = None
"The full URL or path to navigate to after creating an organization."

skip_invitation_screen: bool | None = None
"Controls whether to skip the invitation screen when creating an organization."

hide_slug: bool | None = None
"Controls whether the optional slug field in the Organization creation screen is hidden."

fallback: rx.Component | None = None
"An optional element to be rendered while the component is mounting."


class OrganizationProfile(ClerkBase):
"""
The OrganizationProfile component allows users to manage their organization membership and security settings.

This component renders Clerk's <OrganizationProfile /> React component,
allowing users to manage organization information, members, billing, and security settings.
"""

tag = "OrganizationProfile"

appearance: Appearance | None = None
"Optional object to style your components. Will only affect Clerk components."

path: str | None = None
"The path where the component is mounted when routing is set to 'path'."

routing: str | None = None
"The routing strategy for your pages. Defaults to 'path' for frameworks that handle routing, or 'hash' for other SDKs."

after_leave_organization_url: str | None = None
"The full URL or path to navigate to after leaving an organization."

custom_pages: list | None = None
"An array of custom pages to add to the organization profile."

fallback: rx.Component | None = None
"An optional element to be rendered while the component is mounting."


class OrganizationSwitcher(ClerkBase):
"""
The OrganizationSwitcher component displays the currently active organization and allows users to switch between organizations.

This component renders Clerk's <OrganizationSwitcher /> React component,
providing a dropdown interface for organization switching with customizable appearance.
"""

tag = "OrganizationSwitcher"

appearance: Appearance | None = None
"Optional object to style your components. Will only affect Clerk components."

organization_profile_mode: str | None = None
"Controls whether selecting the organization opens as a modal or navigates to a page. Defaults to 'modal'."

organization_profile_url: str | None = None
"The full URL or path leading to the organization management interface."

organization_profile_props: dict | None = None
"Specify options for the underlying OrganizationProfile component."

create_organization_mode: str | None = None
"Controls whether selecting create organization opens as a modal or navigates to a page. Defaults to 'modal'."

create_organization_url: str | None = None
"The full URL or path leading to the create organization interface."

after_leave_organization_url: str | None = None
"The full URL or path to navigate to after leaving an organization."

after_create_organization_url: str | None = None
"The full URL or path to navigate to after creating an organization."

after_select_organization_url: str | None = None
"The full URL or path to navigate to after selecting an organization."

default_open: bool | None = None
"Controls whether the OrganizationSwitcher should open by default during the first render."

hide_personal: bool | None = None
"Controls whether the personal account option is hidden in the switcher."

hide_slug: bool | None = None
"Controls whether the optional slug field in the Organization creation screen is hidden."

fallback: rx.Component | None = None
"An optional element to be rendered while the component is mounting."


class OrganizationList(ClerkBase):
"""
The OrganizationList component displays a list of organizations that the user is a member of.

This component renders Clerk's <OrganizationList /> React component,
providing an interface to view and manage organization memberships.
"""

tag = "OrganizationList"

appearance: Appearance | None = None
"Optional object to style your components. Will only affect Clerk components."

after_create_organization_url: str | None = None
"The full URL or path to navigate to after creating an organization."

after_select_organization_url: str | None = None
"The full URL or path to navigate to after selecting an organization."

after_select_personal_url: str | None = None
"The full URL or path to navigate to after selecting the personal account."

create_organization_mode: str | None = None
"Controls whether selecting create organization opens as a modal or navigates to a page. Defaults to 'modal'."

create_organization_url: str | None = None
"The full URL or path leading to the create organization interface."

hide_personal: bool | None = None
"Controls whether the personal account option is hidden in the list."

hide_slug: bool | None = None
"Controls whether the optional slug field in the Organization creation screen is hidden."

skip_invitation_screen: bool | None = None
"Controls whether to skip the invitation screen when creating an organization."

fallback: rx.Component | None = None
"An optional element to be rendered while the component is mounting."


create_organization = CreateOrganization.create
organization_profile = OrganizationProfile.create
organization_switcher = OrganizationSwitcher.create
organization_list = OrganizationList.create
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@ path = "custom_components/reflex_clerk_api/__init__.py"
name = "reflex-clerk-api"
description = "Reflex custom component wrapping @clerk/clerk-react and integrating the clerk-backend-api"
readme = "README.md"
license = "Apache-2.0"
license = { text = "Apache-2.0" }
requires-python = ">=3.10"
authors = [{ name = "Tim Child", email = "timjchild@gmail.com" }]
authors = [
{ name = "Tim Child", email = "timjchild@gmail.com" },
{ name = "Paul Johnson", email = "paul.johnson@snaplabs.ai" }
]
keywords = ["reflex","reflex-custom-components", "clerk", "clerk-backend-api"]
dynamic = ["version"]

dependencies = [
"authlib>=1.5.1,<2.0.0",
"clerk-backend-api>=2.0.0,<3.0.0",
"authlib>=1.5.1",
"clerk-backend-api>=4.0.0",
"reflex>=0.7.5",
]

Expand Down
57 changes: 57 additions & 0 deletions tests/test_clerk_provider_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import asyncio
import sys
from pathlib import Path

import authlib.jose.errors as jose_errors


# Ensure tests use the local custom component code (not an installed wheel).
_CUSTOM_COMPONENTS_DIR = Path(__file__).resolve().parents[1] / "custom_components"
sys.path.insert(0, str(_CUSTOM_COMPONENTS_DIR))


def test_set_clerk_session_expired_token_clears(monkeypatch):
"""Expired tokens should not crash the handler; they should clear session."""
# Import inside the test so the module is importable in different test layouts.
from reflex_clerk_api.clerk_provider import ClerkState

import importlib

clerk_provider_module = importlib.import_module("reflex_clerk_api.clerk_provider")

# Instantiate state in a framework-safe way for tests.
state = ClerkState(_reflex_internal_init=True)

async def fake_get_jwk_keys(self):
return {}

monkeypatch.setattr(ClerkState, "_get_jwk_keys", fake_get_jwk_keys, raising=True)

validate_calls: dict[str, object] = {}

class FakeClaims:
def validate(self, leeway=None):
validate_calls["leeway"] = leeway
raise jose_errors.ExpiredTokenError()

monkeypatch.setattr(
clerk_provider_module.jwt,
"decode",
lambda *args, **kwargs: FakeClaims(),
raising=True,
)

result = asyncio.run(ClerkState.set_clerk_session.fn(state, token="fake"))
assert validate_calls["leeway"] == 60
assert result == ClerkState.clear_clerk_session


def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcache():
"""String-based regression test for the generated JS."""
from reflex_clerk_api.clerk_provider import ClerkSessionSynchronizer

js = ClerkSessionSynchronizer.create().add_custom_code()[0]
assert "[isLoaded, isSignedIn, addEvents, getToken]" in js
assert "skipCache: true" in js


Loading