Skip to content
Open
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
51 changes: 32 additions & 19 deletions server/polar/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from fastapi.security import HTTPBearer, OpenIdConnect
from makefun import with_signature

from polar.auth.scope import RESERVED_SCOPES, Scope
from polar.exceptions import Unauthorized
from polar.auth.scope import Scope
from polar.exceptions import NotPermitted, Unauthorized
from polar.oauth2.exceptions import InsufficientScopeError

from .models import (
Expand All @@ -20,6 +20,7 @@
SubjectType,
User,
is_anonymous,
is_web_session,
)

oidc_scheme = OpenIdConnect(
Expand Down Expand Up @@ -192,13 +193,7 @@ def Authenticator(
kind=Parameter.POSITIONAL_OR_KEYWORD,
default=Security(
_get_auth_subject_factory(allowed_subjects_frozen),
scopes=sorted(
[
s.value
for s in (required_scopes or {})
if s not in RESERVED_SCOPES
]
),
scopes=sorted(s.value for s in (required_scopes or {})),
),
),
]
Expand All @@ -218,18 +213,36 @@ async def __call__(

_WebUserOrAnonymous = Authenticator(
allowed_subjects={Anonymous, User},
required_scopes={Scope.web_write},
required_scopes=None,
)


async def _web_user_or_anonymous(
auth_subject: Annotated[
AuthSubject[Anonymous | User], Depends(_WebUserOrAnonymous)
],
) -> AuthSubject[Anonymous | User]:
"""Allow anonymous or web-session users. Reject API tokens."""
if not is_anonymous(auth_subject) and not is_web_session(auth_subject):
raise NotPermitted()
return auth_subject


WebUserOrAnonymous = Annotated[
AuthSubject[Anonymous | User], Depends(_WebUserOrAnonymous)
AuthSubject[Anonymous | User], Depends(_web_user_or_anonymous)
]

_WebUserRead = Authenticator(
allowed_subjects={User}, required_scopes={Scope.web_read, Scope.web_write}
)
WebUserRead = Annotated[AuthSubject[User], Depends(_WebUserRead)]
_WebUserAuth = Authenticator(allowed_subjects={User}, required_scopes=None)

_WebUserWrite = Authenticator(
allowed_subjects={User}, required_scopes={Scope.web_write}
)
WebUserWrite = Annotated[AuthSubject[User], Depends(_WebUserWrite)]

async def _web_user(
auth_subject: Annotated[AuthSubject[User], Depends(_WebUserAuth)],
) -> AuthSubject[User]:
"""Allow web-session users only. Reject API tokens."""
if not is_web_session(auth_subject):
raise NotPermitted()
return auth_subject


WebUserRead = Annotated[AuthSubject[User], Depends(_web_user)]
WebUserWrite = Annotated[AuthSubject[User], Depends(_web_user)]
11 changes: 10 additions & 1 deletion server/polar/auth/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,16 @@ async def get_auth_subject(

user_session = await get_user_session(request, session)
if user_session is not None:
return AuthSubject(user_session.user, set(user_session.scopes), user_session)
scopes = set(user_session.scopes)
# Upgrade legacy web sessions to have all scopes.
# Old sessions were created with exactly {web_read, web_write}.
# New sessions get all scopes. This ensures old sessions work
# until they expire. Safe to remove after 2026-05-22 (31 days).
# Only match the exact legacy set — read-only impersonation
# sessions ({web_read} only) must NOT be upgraded.
if scopes == {Scope.web_read, Scope.web_write}:
scopes = set(Scope)
return AuthSubject(user_session.user, scopes, user_session)

return AuthSubject(Anonymous(), set(), None)

Expand Down
8 changes: 7 additions & 1 deletion server/polar/auth/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from functools import cached_property
from typing import Generic, TypeGuard, TypeVar
from typing import Any, Generic, TypeGuard, TypeVar

from polar.enums import RateLimitGroup
from polar.models import (
Expand Down Expand Up @@ -126,6 +126,11 @@ def is_member[S: Subject](
return isinstance(auth_subject.subject, Member)


def is_web_session(auth_subject: AuthSubject[Any]) -> bool:
"""Check if the auth subject is authenticated via a web session (not API token)."""
return isinstance(auth_subject.session, UserSession)


__all__ = [
# Re-export subject types for convenience
"Anonymous",
Expand All @@ -141,4 +146,5 @@ def is_member[S: Subject](
"is_member",
"is_organization",
"is_user",
"is_web_session",
]
7 changes: 1 addition & 6 deletions server/polar/auth/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from fastapi.routing import APIRoute

from polar.auth.dependencies import _Authenticator
from polar.auth.scope import RESERVED_SCOPES


class DocumentedAuthSubjectAPIRoute(APIRoute):
Expand Down Expand Up @@ -48,11 +47,7 @@ def __init__(
description = kwargs["description"] or inspect.cleandoc(
endpoint.__doc__ or ""
)
scopes_list = [
f"`{s}`"
for s in sorted(required_scopes or [])
if s not in RESERVED_SCOPES
]
scopes_list = [f"`{s}`" for s in sorted(required_scopes or [])]
if scopes_list:
description += f"\n\n**Scopes**: {' '.join(scopes_list)}"
kwargs["description"] = description
Expand Down
40 changes: 38 additions & 2 deletions server/polar/auth/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,44 @@ def __get_pydantic_json_schema__(
return json_schema


RESERVED_SCOPES = {Scope.web_read, Scope.web_write}
SCOPES_SUPPORTED = [s.value for s in Scope if s not in RESERVED_SCOPES]
READ_ONLY_SCOPES: set[Scope] = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea!

Scope.openid,
Scope.profile,
Scope.email,
Scope.user_read,
Scope.web_read,
Scope.organizations_read,
Scope.custom_fields_read,
Scope.discounts_read,
Scope.checkout_links_read,
Scope.checkouts_read,
Scope.transactions_read,
Scope.payouts_read,
Scope.products_read,
Scope.benefits_read,
Scope.events_read,
Scope.meters_read,
Scope.files_read,
Scope.subscriptions_read,
Scope.customers_read,
Scope.members_read,
Scope.wallets_read,
Scope.disputes_read,
Scope.customer_meters_read,
Scope.customer_seats_read,
Scope.orders_read,
Scope.refunds_read,
Scope.payments_read,
Scope.metrics_read,
Scope.webhooks_read,
Scope.license_keys_read,
Scope.customer_portal_read,
Scope.notifications_read,
Scope.notification_recipients_read,
Scope.organization_access_tokens_read,
}

SCOPES_SUPPORTED = [s.value for s in Scope]
SCOPES_SUPPORTED_DISPLAY_NAMES: dict[Scope, str] = {
Scope.openid: "OpenID",
Scope.profile: "Read your profile",
Expand Down
2 changes: 1 addition & 1 deletion server/polar/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def get_login_response(
session=session,
user=user,
user_agent=request.headers.get("User-Agent", ""),
scopes=[Scope.web_read, Scope.web_write],
scopes=list(Scope),
)

return_url = get_safe_return_url(return_to)
Expand Down
41 changes: 33 additions & 8 deletions server/polar/authz/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ def OrgPolicyGuard(

_allowed = allowed_subjects or {User, Organization}
_scopes = required_scopes or {
Scope.web_read,
Scope.web_write,
Scope.organizations_read,
Scope.organizations_write,
}
Expand Down Expand Up @@ -118,19 +116,38 @@ async def _check_policy(


AuthorizeFinanceRead = Annotated[
AuthzContext[User | Organization], Depends(OrgPolicyGuard(finance.can_read))
AuthzContext[User | Organization],
Depends(
OrgPolicyGuard(
finance.can_read,
required_scopes={
Scope.transactions_read,
Scope.transactions_write,
Scope.payouts_read,
Scope.payouts_write,
},
)
),
]
AuthorizeFinanceWrite = Annotated[
AuthzContext[User | Organization],
Depends(OrgPolicyGuard(finance.can_write)),
Depends(
OrgPolicyGuard(
finance.can_write,
required_scopes={
Scope.transactions_write,
Scope.payouts_write,
},
)
),
]
AuthorizeMembersManage = Annotated[
AuthzContext[User],
Depends(
OrgPolicyGuard(
members.can_manage,
allowed_subjects={User},
required_scopes={Scope.web_write, Scope.organizations_write},
required_scopes={Scope.organizations_write},
)
),
]
Expand All @@ -140,7 +157,7 @@ async def _check_policy(
OrgPolicyGuard(
org_policy.can_delete,
allowed_subjects={User},
required_scopes={Scope.web_write, Scope.organizations_write},
required_scopes={Scope.organizations_write},
)
),
]
Expand Down Expand Up @@ -206,7 +223,12 @@ def AccountPolicyGuard(policy_fn: PolicyFn) -> Any:

_authenticator = Authenticator(
allowed_subjects={User},
required_scopes={Scope.web_read, Scope.web_write},
required_scopes={
Scope.transactions_read,
Scope.transactions_write,
Scope.payouts_read,
Scope.payouts_write,
},
)

async def dependency(
Expand Down Expand Up @@ -254,7 +276,10 @@ def PayoutAccountPolicyGuard(

_authenticator = Authenticator(
allowed_subjects={User},
required_scopes={Scope.web_read, Scope.web_write},
required_scopes={
Scope.payouts_read,
Scope.payouts_write,
},
)

async def dependency(
Expand Down
4 changes: 2 additions & 2 deletions server/polar/backoffice/impersonation/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from fastapi.responses import RedirectResponse
from sqlalchemy import select

from polar.auth.scope import Scope
from polar.auth.scope import READ_ONLY_SCOPES
from polar.auth.service import auth as auth_service
from polar.config import settings
from polar.kit.crypto import get_token_hash
Expand Down Expand Up @@ -54,7 +54,7 @@ async def start_impersonation(
session=session,
user=target_user,
user_agent=request.headers.get("User-Agent", ""),
scopes=[Scope.web_read],
scopes=list(READ_ONLY_SCOPES),
expire_in=timedelta(minutes=60),
)

Expand Down
4 changes: 2 additions & 2 deletions server/polar/backoffice/organizations_v2/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from polar.account.service import account as account_service
from polar.account_credit.repository import AccountCreditRepository
from polar.account_credit.service import account_credit_service
from polar.auth.scope import Scope
from polar.auth.scope import READ_ONLY_SCOPES
from polar.auth.service import auth as auth_service
from polar.backoffice.organizations.analytics import (
OrganizationSetupAnalyticsService,
Expand Down Expand Up @@ -2715,7 +2715,7 @@ async def impersonate_user(
session=session,
user=user,
user_agent=request.headers.get("User-Agent", ""),
scopes=[Scope.web_read], # Read-only
scopes=list(READ_ONLY_SCOPES),
expire_in=timedelta(minutes=60), # Time-limited
)

Expand Down
3 changes: 0 additions & 3 deletions server/polar/benefit/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

_BenefitsRead = Authenticator(
required_scopes={
Scope.web_read,
Scope.web_write,
Scope.benefits_read,
Scope.benefits_write,
},
Expand All @@ -19,7 +17,6 @@

_BenefitsWrite = Authenticator(
required_scopes={
Scope.web_write,
Scope.benefits_write,
},
allowed_subjects={User, Organization},
Expand Down
4 changes: 1 addition & 3 deletions server/polar/checkout/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

_CheckoutRead = Authenticator(
required_scopes={
Scope.web_read,
Scope.web_write,
Scope.checkouts_read,
Scope.checkouts_write,
},
Expand All @@ -25,7 +23,7 @@
CheckoutWrite = Annotated[AuthSubject[User | Organization], Depends(_CheckoutWrite)]

_CheckoutWeb = Authenticator(
required_scopes={Scope.web_write},
required_scopes=set(),
allowed_subjects={User, Anonymous},
)
CheckoutWeb = Annotated[AuthSubject[User | Anonymous], Depends(_CheckoutWeb)]
3 changes: 0 additions & 3 deletions server/polar/checkout_link/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

_CheckoutLinkRead = Authenticator(
required_scopes={
Scope.web_read,
Scope.web_write,
Scope.checkout_links_read,
Scope.checkout_links_write,
},
Expand All @@ -22,7 +20,6 @@

_CheckoutLinkWrite = Authenticator(
required_scopes={
Scope.web_write,
Scope.checkout_links_write,
},
allowed_subjects={User, Organization},
Expand Down
2 changes: 0 additions & 2 deletions server/polar/cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

_CLIRead = Authenticator(
required_scopes={
Scope.web_read,
Scope.web_write,
Scope.webhooks_read,
Scope.webhooks_write,
},
Expand Down
Loading
Loading