From 6db4540ce745a726c6563fb69267a6462c1de4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Jan 2026 15:40:33 +0100 Subject: [PATCH 1/8] server/integrations/google: split oauth2 into two distinct login and link flows --- server/polar/auth/service.py | 2 +- server/polar/config.py | 4 + server/polar/integrations/google/endpoints.py | 222 +++++++++++++----- 3 files changed, 168 insertions(+), 60 deletions(-) diff --git a/server/polar/auth/service.py b/server/polar/auth/service.py index 522b089c30..b19995e2fc 100644 --- a/server/polar/auth/service.py +++ b/server/polar/auth/service.py @@ -141,7 +141,7 @@ async def _create_user_session( def _set_user_session_cookie( self, request: Request, response: R, value: str, expires: int | datetime ) -> R: - is_localhost = request.url.hostname in ["127.0.0.1", "localhost"] + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} secure = False if is_localhost else True response.set_cookie( settings.USER_SESSION_COOKIE_KEY, diff --git a/server/polar/config.py b/server/polar/config.py index fc90251a8b..362735e735 100644 --- a/server/polar/config.py +++ b/server/polar/config.py @@ -122,6 +122,10 @@ class Settings(BaseSettings): LOGIN_CODE_TTL_SECONDS: int = 60 * 30 # 30 minutes LOGIN_CODE_LENGTH: int = 6 + # Social login session + SOCIAL_LOGIN_SESSION_TTL: timedelta = timedelta(minutes=10) + SOCIAL_LOGIN_SESSION_COOKIE_KEY: str = "polar_social_login_session" + # App Review bypass (for testing login flow during Apple/Google app reviews) APP_REVIEW_EMAIL: str | None = None APP_REVIEW_OTP_CODE: str | None = None diff --git a/server/polar/integrations/google/endpoints.py b/server/polar/integrations/google/endpoints.py index c2a6db4307..6c5a5683fd 100644 --- a/server/polar/integrations/google/endpoints.py +++ b/server/polar/integrations/google/endpoints.py @@ -6,14 +6,14 @@ from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback from httpx_oauth.oauth2 import OAuth2Token -from polar.auth.dependencies import WebUserOrAnonymous +from polar.auth.dependencies import WebUserOrAnonymous, WebUserWrite from polar.auth.models import is_user from polar.auth.service import auth as auth_service from polar.config import settings -from polar.exceptions import PolarRedirectionError +from polar.exceptions import NotPermitted, PolarRedirectionError from polar.integrations.loops.service import loops as loops_service from polar.kit import jwt -from polar.kit.http import ReturnTo +from polar.kit.http import ReturnTo, get_safe_return_url from polar.openapi import APITag from polar.postgres import AsyncSession, get_db_session from polar.posthog import posthog @@ -23,75 +23,129 @@ from .service import GoogleServiceError, google_oauth_client from .service import google as google_service -oauth2_authorize_callback = OAuth2AuthorizeCallback( - google_oauth_client, route_name="integrations.google.callback" +oauth2_login_authorize_callback = OAuth2AuthorizeCallback( + google_oauth_client, route_name="integrations.google.login.callback" ) +oauth2_link_authorize_callback = OAuth2AuthorizeCallback( + google_oauth_client, route_name="integrations.google.link.callback" +) + + +GOOGLE_OAUTH_SCOPES = [ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", +] class OAuthCallbackError(PolarRedirectionError): ... -router = APIRouter( - prefix="/integrations/google", - tags=["integrations_google", APITag.private], +def set_login_cookie( + request: Request, response: RedirectResponse, encoded_state: str +) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value=encoded_state, + max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), + path="/", + secure=secure, + httponly=True, + ) + + +def clear_login_cookie(request: Request, response: RedirectResponse) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value="", + max_age=0, + path="/", + secure=secure, + httponly=True, + ) + + +def validate_oauth_callback( + request: Request, token_data: OAuth2Token, state: str | None +) -> dict[str, Any]: + error_description = token_data.get("error_description") + if error_description: + raise OAuthCallbackError(error_description) + + if not state: + raise OAuthCallbackError("No state") + + session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) + if session_cookie is None or session_cookie != state: + raise OAuthCallbackError("Invalid session cookie") + + try: + return jwt.decode(token=state, secret=settings.SECRET, type="google_oauth") + except jwt.DecodeError as e: + raise OAuthCallbackError("Invalid state") from e + + +async def create_authorization_response( + request: Request, + state: dict[str, Any], + callback_route: str, +) -> RedirectResponse: + encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="google_oauth") + redirect_uri = str(request.url_for(callback_route)) + authorization_url = await google_oauth_client.get_authorization_url( + redirect_uri=redirect_uri, + state=encoded_state, + scope=GOOGLE_OAUTH_SCOPES, + ) + response = RedirectResponse(authorization_url, 303) + set_login_cookie(request, response, encoded_state) + return response + + +login_router = APIRouter( + prefix="/login", + tags=["integrations_google_login", APITag.private], ) -@router.get("/authorize", name="integrations.google.authorize") -async def google_authorize( +@login_router.get("/authorize", name="integrations.google.login.authorize") +async def login_authorize( request: Request, auth_subject: WebUserOrAnonymous, return_to: ReturnTo, signup_attribution: UserSignupAttributionQuery, ) -> RedirectResponse: - state: dict[str, Any] = {} - - state["return_to"] = return_to + if is_user(auth_subject): + raise NotPermitted() + state: dict[str, Any] = {"return_to": return_to} if signup_attribution: state["signup_attribution"] = signup_attribution.model_dump(exclude_unset=True) - if is_user(auth_subject): - state["user_id"] = str(auth_subject.subject.id) - - encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="google_oauth") - redirect_uri = str(request.url_for("integrations.google.callback")) - authorization_url = await google_oauth_client.get_authorization_url( - redirect_uri=redirect_uri, - state=encoded_state, - scope=[ - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - ], + return await create_authorization_response( + request, state, "integrations.google.login.callback" ) - return RedirectResponse(authorization_url, 303) -@router.get("/callback", name="integrations.google.callback") -async def google_callback( +@login_router.get("/callback", name="integrations.google.login.callback") +async def login_callback( request: Request, auth_subject: WebUserOrAnonymous, session: AsyncSession = Depends(get_db_session), access_token_state: tuple[OAuth2Token, str | None] = Depends( - oauth2_authorize_callback + oauth2_login_authorize_callback ), ) -> RedirectResponse: - token_data, state = access_token_state - error_description = token_data.get("error_description") - if error_description: - raise OAuthCallbackError(error_description) - if not state: - raise OAuthCallbackError("No state") + if is_user(auth_subject): + raise NotPermitted() - try: - state_data = jwt.decode( - token=state, secret=settings.SECRET, type="google_oauth" - ) - except jwt.DecodeError as e: - raise OAuthCallbackError("Invalid state") from e + token_data, state = access_token_state + state_data = validate_oauth_callback(request, token_data, state) return_to = state_data.get("return_to", None) - state_user_id = state_data.get("user_id") state_signup_attribution = state_data.get("signup_attribution") if state_signup_attribution: @@ -100,25 +154,14 @@ async def google_callback( ) try: - if ( - is_user(auth_subject) - and state_user_id is not None - and auth_subject.subject.id == uuid.UUID(state_user_id) - ): - is_signup = False - user = await google_service.link_user( - session, user=auth_subject.subject, token=token_data - ) - else: - user, is_signup = await google_service.get_updated_or_create( - session, - token=token_data, - signup_attribution=state_signup_attribution, - ) + user, is_signup = await google_service.get_updated_or_create( + session, + token=token_data, + signup_attribution=state_signup_attribution, + ) except GoogleServiceError as e: raise OAuthCallbackError(e.message, e.status_code, return_to=return_to) from e - # Event tracking last to ensure business critical data is stored first if is_signup: posthog.user_signup(user, "google") await loops_service.user_signup(user, googleLogin=True) @@ -126,6 +169,67 @@ async def google_callback( posthog.user_login(user, "google") await loops_service.user_update(session, user, googleLogin=True) - return await auth_service.get_login_response( + response = await auth_service.get_login_response( session, request, user, return_to=return_to ) + clear_login_cookie(request, response) + return response + + +link_router = APIRouter( + prefix="/link", + tags=["integrations_google_link", APITag.private], +) + + +@link_router.get("/authorize", name="integrations.google.link.authorize") +async def link_authorize( + request: Request, auth_subject: WebUserWrite, return_to: ReturnTo +) -> RedirectResponse: + state: dict[str, Any] = { + "return_to": return_to, + "user_id": str(auth_subject.subject.id), + } + + return await create_authorization_response( + request, state, "integrations.google.link.callback" + ) + + +@link_router.get("/callback", name="integrations.google.link.callback") +async def link_callback( + request: Request, + auth_subject: WebUserWrite, + session: AsyncSession = Depends(get_db_session), + access_token_state: tuple[OAuth2Token, str | None] = Depends( + oauth2_link_authorize_callback + ), +) -> RedirectResponse: + token_data, state = access_token_state + state_data = validate_oauth_callback(request, token_data, state) + + return_to = state_data.get("return_to", None) + state_user_id = state_data.get("user_id") + + if state_user_id is None or auth_subject.subject.id != uuid.UUID(state_user_id): + raise OAuthCallbackError("Invalid user for linking", return_to=return_to) + + try: + await google_service.link_user( + session, user=auth_subject.subject, token=token_data + ) + except GoogleServiceError as e: + raise OAuthCallbackError(e.message, e.status_code, return_to=return_to) from e + + return_url = get_safe_return_url(return_to) + response = RedirectResponse(return_url, 303) + clear_login_cookie(request, response) + return response + + +router = APIRouter( + prefix="/integrations/google", + tags=["integrations_google", APITag.private], +) +router.include_router(login_router) +router.include_router(link_router) From cb5ca0ff75dd429b40a63464b849daf9ba6a6032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Jan 2026 15:56:44 +0100 Subject: [PATCH 2/8] server/integrations/github: split oauth2 into two distinct login and link flows --- server/polar/integrations/github/endpoints.py | 220 +++++++++++++----- 1 file changed, 162 insertions(+), 58 deletions(-) diff --git a/server/polar/integrations/github/endpoints.py b/server/polar/integrations/github/endpoints.py index 6f73ba9230..6bccbbb230 100644 --- a/server/polar/integrations/github/endpoints.py +++ b/server/polar/integrations/github/endpoints.py @@ -7,14 +7,14 @@ from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback from httpx_oauth.oauth2 import OAuth2Token -from polar.auth.dependencies import WebUserOrAnonymous +from polar.auth.dependencies import WebUserOrAnonymous, WebUserWrite from polar.auth.models import is_user from polar.auth.service import auth as auth_service from polar.config import settings from polar.exceptions import NotPermitted, PolarRedirectionError from polar.integrations.loops.service import loops as loops_service from polar.kit import jwt -from polar.kit.http import ReturnTo +from polar.kit.http import ReturnTo, get_safe_return_url from polar.openapi import APITag from polar.postgres import AsyncSession, get_db_session from polar.posthog import posthog @@ -24,19 +24,20 @@ from .service.secret_scanning import secret_scanning as secret_scanning_service from .service.user import GithubUserServiceError, github_user -router = APIRouter( - prefix="/integrations/github", tags=["integrations_github", APITag.private] -) - - github_oauth_client = GitHubOAuth2( settings.GITHUB_CLIENT_ID, settings.GITHUB_CLIENT_SECRET ) -oauth2_authorize_callback = OAuth2AuthorizeCallback( - github_oauth_client, route_name="integrations.github.callback" +oauth2_login_authorize_callback = OAuth2AuthorizeCallback( + github_oauth_client, route_name="integrations.github.login.callback" +) +oauth2_link_authorize_callback = OAuth2AuthorizeCallback( + github_oauth_client, route_name="integrations.github.link.callback" ) +GITHUB_OAUTH_SCOPES = ["user", "user:email"] + + class OAuthCallbackError(PolarRedirectionError): ... @@ -46,60 +47,115 @@ def __init__(self) -> None: super().__init__(message) -@router.get("/authorize", name="integrations.github.authorize") -async def github_authorize( +def set_login_cookie( + request: Request, response: RedirectResponse, encoded_state: str +) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value=encoded_state, + max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), + path="/", + secure=secure, + httponly=True, + ) + + +def clear_login_cookie(request: Request, response: RedirectResponse) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value="", + max_age=0, + path="/", + secure=secure, + httponly=True, + ) + + +def validate_oauth_callback( + request: Request, token_data: OAuth2Token, state: str | None +) -> dict[str, Any]: + error_description = token_data.get("error_description") + if error_description: + raise OAuthCallbackError(error_description) + + if not state: + raise OAuthCallbackError("No state") + + session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) + if session_cookie is None or session_cookie != state: + raise OAuthCallbackError("Invalid session cookie") + + try: + return jwt.decode(token=state, secret=settings.SECRET, type="github_oauth") + except jwt.DecodeError as e: + raise OAuthCallbackError("Invalid state") from e + + +async def create_authorization_response( + request: Request, + state: dict[str, Any], + callback_route: str, +) -> RedirectResponse: + encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="github_oauth") + redirect_uri = str(request.url_for(callback_route)) + authorization_url = await github_oauth_client.get_authorization_url( + redirect_uri=redirect_uri, + state=encoded_state, + scope=GITHUB_OAUTH_SCOPES, + ) + response = RedirectResponse(authorization_url, 303) + set_login_cookie(request, response, encoded_state) + return response + + +login_router = APIRouter( + prefix="/login", + tags=["integrations_github_login", APITag.private], +) + + +@login_router.get("/authorize", name="integrations.github.login.authorize") +async def login_authorize( request: Request, auth_subject: WebUserOrAnonymous, return_to: ReturnTo, signup_attribution: UserSignupAttributionQuery, payment_intent_id: str | None = None, ) -> RedirectResponse: - state: dict[str, Any] = {} + if is_user(auth_subject): + raise NotPermitted() + + state: dict[str, Any] = {"return_to": return_to} if payment_intent_id: state["payment_intent_id"] = payment_intent_id - - state["return_to"] = return_to - if signup_attribution: state["signup_attribution"] = signup_attribution.model_dump(exclude_unset=True) - if is_user(auth_subject): - state["user_id"] = str(auth_subject.subject.id) - - encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="github_oauth") - authorization_url = await github_oauth_client.get_authorization_url( - redirect_uri=str(request.url_for("integrations.github.callback")), - state=encoded_state, - scope=["user", "user:email"], + return await create_authorization_response( + request, state, "integrations.github.login.callback" ) - return RedirectResponse(authorization_url, 303) -@router.get("/callback", name="integrations.github.callback") -async def github_callback( +@login_router.get("/callback", name="integrations.github.login.callback") +async def login_callback( request: Request, auth_subject: WebUserOrAnonymous, session: AsyncSession = Depends(get_db_session), access_token_state: tuple[OAuth2Token, str | None] = Depends( - oauth2_authorize_callback + oauth2_login_authorize_callback ), ) -> RedirectResponse: - token_data, state = access_token_state - error_description = token_data.get("error_description") - if error_description: - raise OAuthCallbackError(error_description) - if not state: - raise OAuthCallbackError("No state") + if is_user(auth_subject): + raise NotPermitted() - try: - state_data = jwt.decode( - token=state, secret=settings.SECRET, type="github_oauth" - ) - except jwt.DecodeError as e: - raise OAuthCallbackError("Invalid state") from e + token_data, state = access_token_state + state_data = validate_oauth_callback(request, token_data, state) return_to = state_data.get("return_to", None) - state_user_id = state_data.get("user_id") state_signup_attribution = state_data.get("signup_attribution") if state_signup_attribution: @@ -108,26 +164,14 @@ async def github_callback( ) try: - if ( - is_user(auth_subject) - and state_user_id is not None - and auth_subject.subject.id == UUID(state_user_id) - ): - is_signup = False - user = await github_user.link_user( - session, user=auth_subject.subject, token=token_data - ) - else: - user, is_signup = await github_user.get_updated_or_create( - session, - token=token_data, - signup_attribution=state_signup_attribution, - ) - + user, is_signup = await github_user.get_updated_or_create( + session, + token=token_data, + signup_attribution=state_signup_attribution, + ) except GithubUserServiceError as e: raise OAuthCallbackError(e.message, e.status_code, return_to=return_to) from e - # Event tracking last to ensure business critical data is stored first if is_signup: posthog.user_signup(user, "github") await loops_service.user_signup(user, githubLogin=True) @@ -135,9 +179,69 @@ async def github_callback( posthog.user_login(user, "github") await loops_service.user_update(session, user, githubLogin=True) - return await auth_service.get_login_response( + response = await auth_service.get_login_response( session, request, user, return_to=return_to ) + clear_login_cookie(request, response) + return response + + +link_router = APIRouter( + prefix="/link", + tags=["integrations_github_link", APITag.private], +) + + +@link_router.get("/authorize", name="integrations.github.link.authorize") +async def link_authorize( + request: Request, auth_subject: WebUserWrite, return_to: ReturnTo +) -> RedirectResponse: + state: dict[str, Any] = { + "return_to": return_to, + "user_id": str(auth_subject.subject.id), + } + + return await create_authorization_response( + request, state, "integrations.github.link.callback" + ) + + +@link_router.get("/callback", name="integrations.github.link.callback") +async def link_callback( + request: Request, + auth_subject: WebUserWrite, + session: AsyncSession = Depends(get_db_session), + access_token_state: tuple[OAuth2Token, str | None] = Depends( + oauth2_link_authorize_callback + ), +) -> RedirectResponse: + token_data, state = access_token_state + state_data = validate_oauth_callback(request, token_data, state) + + return_to = state_data.get("return_to", None) + state_user_id = state_data.get("user_id") + + if state_user_id is None or auth_subject.subject.id != UUID(state_user_id): + raise OAuthCallbackError("Invalid user for linking", return_to=return_to) + + try: + await github_user.link_user( + session, user=auth_subject.subject, token=token_data + ) + except GithubUserServiceError as e: + raise OAuthCallbackError(e.message, e.status_code, return_to=return_to) from e + + return_url = get_safe_return_url(return_to) + response = RedirectResponse(return_url, 303) + clear_login_cookie(request, response) + return response + + +router = APIRouter( + prefix="/integrations/github", tags=["integrations_github", APITag.private] +) +router.include_router(login_router) +router.include_router(link_router) @router.post("/secret-scanning", include_in_schema=False) From 64027b045decb63b317f82f2017ddf0a230f6896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Jan 2026 16:24:46 +0100 Subject: [PATCH 3/8] server/integrations/apple: remove account linking implementation, only login flow --- server/polar/integrations/apple/endpoints.py | 80 +++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/server/polar/integrations/apple/endpoints.py b/server/polar/integrations/apple/endpoints.py index 722e6421e5..340eef5f64 100644 --- a/server/polar/integrations/apple/endpoints.py +++ b/server/polar/integrations/apple/endpoints.py @@ -1,4 +1,3 @@ -import uuid from typing import Any from fastapi import Depends, Form, Request @@ -12,7 +11,7 @@ from polar.auth.models import is_user from polar.auth.service import auth as auth_service from polar.config import settings -from polar.exceptions import PolarRedirectionError +from polar.exceptions import NotPermitted, PolarRedirectionError from polar.integrations.loops.service import loops as loops_service from polar.kit import jwt from polar.kit.http import ReturnTo @@ -35,6 +34,36 @@ class OAuthCallbackError(PolarRedirectionError): ... ) +def set_login_cookie( + request: Request, response: RedirectResponse, encoded_state: str +) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value=encoded_state, + max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), + path="/", + secure=secure, + httponly=True, + samesite="none", # Required since Apple uses form post which is cross-site + ) + + +def clear_login_cookie(request: Request, response: RedirectResponse) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value="", + max_age=0, + path="/", + secure=secure, + httponly=True, + samesite="none", # Required since Apple uses form post which is cross-site + ) + + @router.get("/authorize", name="integrations.apple.authorize") async def apple_authorize( request: Request, @@ -42,16 +71,13 @@ async def apple_authorize( return_to: ReturnTo, signup_attribution: UserSignupAttributionQuery, ) -> RedirectResponse: - state: dict[str, Any] = {} - - state["return_to"] = return_to + if is_user(auth_subject): + raise NotPermitted() + state: dict[str, Any] = {"return_to": return_to} if signup_attribution: state["signup_attribution"] = signup_attribution.model_dump(exclude_unset=True) - if is_user(auth_subject): - state["user_id"] = str(auth_subject.subject.id) - encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="apple_oauth") redirect_uri = str(request.url_for("integrations.apple.callback")) apple_oauth_client = get_apple_oauth_client() @@ -60,7 +86,9 @@ async def apple_authorize( state=encoded_state, extras_params={"response_mode": "form_post"}, ) - return RedirectResponse(authorization_url, 303) + response = RedirectResponse(authorization_url, 303) + set_login_cookie(request, response, encoded_state) + return response @router.post("/callback", name="integrations.apple.callback") @@ -73,6 +101,9 @@ async def apple_callback( error: str | None = Form(None), session: AsyncSession = Depends(get_db_session), ) -> RedirectResponse: + if is_user(auth_subject): + raise NotPermitted() + if code is None or error is not None: raise OAuth2AuthorizeCallbackError( status_code=400, @@ -95,16 +126,20 @@ async def apple_callback( error_description = token_data.get("error_description") if error_description: raise OAuthCallbackError(error_description) + if not state: raise OAuthCallbackError("No state") + session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) + if session_cookie is None or session_cookie != state: + raise OAuthCallbackError("Invalid session cookie") + try: state_data = jwt.decode(token=state, secret=settings.SECRET, type="apple_oauth") except jwt.DecodeError as e: raise OAuthCallbackError("Invalid state") from e return_to = state_data.get("return_to", None) - state_user_id = state_data.get("user_id") state_signup_attribution = state_data.get("signup_attribution") if state_signup_attribution: @@ -113,25 +148,14 @@ async def apple_callback( ) try: - if ( - is_user(auth_subject) - and state_user_id is not None - and auth_subject.subject.id == uuid.UUID(state_user_id) - ): - is_signup = False - user = await apple_service.link_user( - session, user=auth_subject.subject, token=token_data - ) - else: - user, is_signup = await apple_service.get_updated_or_create( - session, - token=token_data, - signup_attribution=state_signup_attribution, - ) + user, is_signup = await apple_service.get_updated_or_create( + session, + token=token_data, + signup_attribution=state_signup_attribution, + ) except AppleServiceError as e: raise OAuthCallbackError(e.message, e.status_code, return_to=return_to) from e - # Event tracking last to ensure business critical data is stored first if is_signup: posthog.user_signup(user, "apple") await loops_service.user_signup(user) @@ -139,6 +163,8 @@ async def apple_callback( posthog.user_login(user, "apple") await loops_service.user_update(session, user) - return await auth_service.get_login_response( + response = await auth_service.get_login_response( session, request, user, return_to=return_to ) + clear_login_cookie(request, response) + return response From afd5c6c8cdfd9382237a3b420701f893064fea35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Jan 2026 16:36:03 +0100 Subject: [PATCH 4/8] server/kit/oauth: factorize common oauth login logic --- server/polar/integrations/apple/endpoints.py | 67 +++----------- server/polar/integrations/github/endpoints.py | 67 +++----------- server/polar/integrations/google/endpoints.py | 68 +++------------ server/polar/kit/oauth.py | 87 +++++++++++++++++++ 4 files changed, 121 insertions(+), 168 deletions(-) create mode 100644 server/polar/kit/oauth.py diff --git a/server/polar/integrations/apple/endpoints.py b/server/polar/integrations/apple/endpoints.py index 340eef5f64..fdc4f3a523 100644 --- a/server/polar/integrations/apple/endpoints.py +++ b/server/polar/integrations/apple/endpoints.py @@ -10,11 +10,16 @@ from polar.auth.dependencies import WebUserOrAnonymous from polar.auth.models import is_user from polar.auth.service import auth as auth_service -from polar.config import settings -from polar.exceptions import NotPermitted, PolarRedirectionError +from polar.exceptions import NotPermitted from polar.integrations.loops.service import loops as loops_service -from polar.kit import jwt from polar.kit.http import ReturnTo +from polar.kit.oauth import ( + OAuthCallbackError, + clear_login_cookie, + encode_state, + set_login_cookie, + validate_callback, +) from polar.openapi import APITag from polar.postgres import AsyncSession, get_db_session from polar.posthog import posthog @@ -24,46 +29,12 @@ from .service import AppleServiceError, get_apple_oauth_client from .service import apple as apple_service - -class OAuthCallbackError(PolarRedirectionError): ... - - router = APIRouter( prefix="/integrations/apple", tags=["integrations_apple", APITag.private], ) -def set_login_cookie( - request: Request, response: RedirectResponse, encoded_state: str -) -> None: - is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} - secure = False if is_localhost else True - response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, - value=encoded_state, - max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), - path="/", - secure=secure, - httponly=True, - samesite="none", # Required since Apple uses form post which is cross-site - ) - - -def clear_login_cookie(request: Request, response: RedirectResponse) -> None: - is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} - secure = False if is_localhost else True - response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, - value="", - max_age=0, - path="/", - secure=secure, - httponly=True, - samesite="none", # Required since Apple uses form post which is cross-site - ) - - @router.get("/authorize", name="integrations.apple.authorize") async def apple_authorize( request: Request, @@ -78,7 +49,7 @@ async def apple_authorize( if signup_attribution: state["signup_attribution"] = signup_attribution.model_dump(exclude_unset=True) - encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="apple_oauth") + encoded_state = encode_state(state, type="apple_oauth") redirect_uri = str(request.url_for("integrations.apple.callback")) apple_oauth_client = get_apple_oauth_client() authorization_url = await apple_oauth_client.get_authorization_url( @@ -87,7 +58,7 @@ async def apple_authorize( extras_params={"response_mode": "form_post"}, ) response = RedirectResponse(authorization_url, 303) - set_login_cookie(request, response, encoded_state) + set_login_cookie(request, response, encoded_state, cross_site=True) return response @@ -123,21 +94,7 @@ async def apple_callback( response=e.response, ) from e - error_description = token_data.get("error_description") - if error_description: - raise OAuthCallbackError(error_description) - - if not state: - raise OAuthCallbackError("No state") - - session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) - if session_cookie is None or session_cookie != state: - raise OAuthCallbackError("Invalid session cookie") - - try: - state_data = jwt.decode(token=state, secret=settings.SECRET, type="apple_oauth") - except jwt.DecodeError as e: - raise OAuthCallbackError("Invalid state") from e + state_data = validate_callback(request, token_data, state, type="apple_oauth") return_to = state_data.get("return_to", None) @@ -166,5 +123,5 @@ async def apple_callback( response = await auth_service.get_login_response( session, request, user, return_to=return_to ) - clear_login_cookie(request, response) + clear_login_cookie(request, response, cross_site=True) return response diff --git a/server/polar/integrations/github/endpoints.py b/server/polar/integrations/github/endpoints.py index 6bccbbb230..9c4c357cff 100644 --- a/server/polar/integrations/github/endpoints.py +++ b/server/polar/integrations/github/endpoints.py @@ -11,10 +11,16 @@ from polar.auth.models import is_user from polar.auth.service import auth as auth_service from polar.config import settings -from polar.exceptions import NotPermitted, PolarRedirectionError +from polar.exceptions import NotPermitted from polar.integrations.loops.service import loops as loops_service -from polar.kit import jwt from polar.kit.http import ReturnTo, get_safe_return_url +from polar.kit.oauth import ( + OAuthCallbackError, + clear_login_cookie, + encode_state, + set_login_cookie, + validate_callback, +) from polar.openapi import APITag from polar.postgres import AsyncSession, get_db_session from polar.posthog import posthog @@ -38,69 +44,18 @@ GITHUB_OAUTH_SCOPES = ["user", "user:email"] -class OAuthCallbackError(PolarRedirectionError): ... - - class NotPermittedOrganizationBillingPlan(NotPermitted): def __init__(self) -> None: message = "Organization billing plan not accessible." super().__init__(message) -def set_login_cookie( - request: Request, response: RedirectResponse, encoded_state: str -) -> None: - is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} - secure = False if is_localhost else True - response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, - value=encoded_state, - max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), - path="/", - secure=secure, - httponly=True, - ) - - -def clear_login_cookie(request: Request, response: RedirectResponse) -> None: - is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} - secure = False if is_localhost else True - response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, - value="", - max_age=0, - path="/", - secure=secure, - httponly=True, - ) - - -def validate_oauth_callback( - request: Request, token_data: OAuth2Token, state: str | None -) -> dict[str, Any]: - error_description = token_data.get("error_description") - if error_description: - raise OAuthCallbackError(error_description) - - if not state: - raise OAuthCallbackError("No state") - - session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) - if session_cookie is None or session_cookie != state: - raise OAuthCallbackError("Invalid session cookie") - - try: - return jwt.decode(token=state, secret=settings.SECRET, type="github_oauth") - except jwt.DecodeError as e: - raise OAuthCallbackError("Invalid state") from e - - async def create_authorization_response( request: Request, state: dict[str, Any], callback_route: str, ) -> RedirectResponse: - encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="github_oauth") + encoded_state = encode_state(state, type="github_oauth") redirect_uri = str(request.url_for(callback_route)) authorization_url = await github_oauth_client.get_authorization_url( redirect_uri=redirect_uri, @@ -153,7 +108,7 @@ async def login_callback( raise NotPermitted() token_data, state = access_token_state - state_data = validate_oauth_callback(request, token_data, state) + state_data = validate_callback(request, token_data, state, type="github_oauth") return_to = state_data.get("return_to", None) @@ -216,7 +171,7 @@ async def link_callback( ), ) -> RedirectResponse: token_data, state = access_token_state - state_data = validate_oauth_callback(request, token_data, state) + state_data = validate_callback(request, token_data, state, type="github_oauth") return_to = state_data.get("return_to", None) state_user_id = state_data.get("user_id") diff --git a/server/polar/integrations/google/endpoints.py b/server/polar/integrations/google/endpoints.py index 6c5a5683fd..571741c770 100644 --- a/server/polar/integrations/google/endpoints.py +++ b/server/polar/integrations/google/endpoints.py @@ -9,11 +9,16 @@ from polar.auth.dependencies import WebUserOrAnonymous, WebUserWrite from polar.auth.models import is_user from polar.auth.service import auth as auth_service -from polar.config import settings -from polar.exceptions import NotPermitted, PolarRedirectionError +from polar.exceptions import NotPermitted from polar.integrations.loops.service import loops as loops_service -from polar.kit import jwt from polar.kit.http import ReturnTo, get_safe_return_url +from polar.kit.oauth import ( + OAuthCallbackError, + clear_login_cookie, + encode_state, + set_login_cookie, + validate_callback, +) from polar.openapi import APITag from polar.postgres import AsyncSession, get_db_session from polar.posthog import posthog @@ -37,63 +42,12 @@ ] -class OAuthCallbackError(PolarRedirectionError): ... - - -def set_login_cookie( - request: Request, response: RedirectResponse, encoded_state: str -) -> None: - is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} - secure = False if is_localhost else True - response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, - value=encoded_state, - max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), - path="/", - secure=secure, - httponly=True, - ) - - -def clear_login_cookie(request: Request, response: RedirectResponse) -> None: - is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} - secure = False if is_localhost else True - response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, - value="", - max_age=0, - path="/", - secure=secure, - httponly=True, - ) - - -def validate_oauth_callback( - request: Request, token_data: OAuth2Token, state: str | None -) -> dict[str, Any]: - error_description = token_data.get("error_description") - if error_description: - raise OAuthCallbackError(error_description) - - if not state: - raise OAuthCallbackError("No state") - - session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) - if session_cookie is None or session_cookie != state: - raise OAuthCallbackError("Invalid session cookie") - - try: - return jwt.decode(token=state, secret=settings.SECRET, type="google_oauth") - except jwt.DecodeError as e: - raise OAuthCallbackError("Invalid state") from e - - async def create_authorization_response( request: Request, state: dict[str, Any], callback_route: str, ) -> RedirectResponse: - encoded_state = jwt.encode(data=state, secret=settings.SECRET, type="google_oauth") + encoded_state = encode_state(state, type="google_oauth") redirect_uri = str(request.url_for(callback_route)) authorization_url = await google_oauth_client.get_authorization_url( redirect_uri=redirect_uri, @@ -143,7 +97,7 @@ async def login_callback( raise NotPermitted() token_data, state = access_token_state - state_data = validate_oauth_callback(request, token_data, state) + state_data = validate_callback(request, token_data, state, type="google_oauth") return_to = state_data.get("return_to", None) @@ -206,7 +160,7 @@ async def link_callback( ), ) -> RedirectResponse: token_data, state = access_token_state - state_data = validate_oauth_callback(request, token_data, state) + state_data = validate_callback(request, token_data, state, type="google_oauth") return_to = state_data.get("return_to", None) state_user_id = state_data.get("user_id") diff --git a/server/polar/kit/oauth.py b/server/polar/kit/oauth.py new file mode 100644 index 0000000000..a6d9881956 --- /dev/null +++ b/server/polar/kit/oauth.py @@ -0,0 +1,87 @@ +from typing import Any, Literal + +from fastapi import Request +from fastapi.responses import RedirectResponse +from httpx_oauth.oauth2 import OAuth2Token + +from polar.config import settings +from polar.exceptions import PolarRedirectionError +from polar.kit import jwt + + +class OAuthCallbackError(PolarRedirectionError): + pass + + +OAuthStateType = Literal["github_oauth", "google_oauth", "apple_oauth"] + + +def encode_state(state: dict[str, Any], *, type: OAuthStateType) -> str: + return jwt.encode(data=state, secret=settings.SECRET, type=type) + + +def decode_state(state: str, *, type: OAuthStateType) -> dict[str, Any]: + try: + return jwt.decode(token=state, secret=settings.SECRET, type=type) + except jwt.DecodeError as e: + raise OAuthCallbackError("Invalid state") from e + + +def set_login_cookie( + request: Request, + response: RedirectResponse, + encoded_state: str, + *, + cross_site: bool = False, +) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value=encoded_state, + max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), + path="/", + secure=secure, + httponly=True, + samesite="none" if cross_site else "lax", + ) + + +def clear_login_cookie( + request: Request, + response: RedirectResponse, + *, + cross_site: bool = False, +) -> None: + is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} + secure = False if is_localhost else True + response.set_cookie( + settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + value="", + max_age=0, + path="/", + secure=secure, + httponly=True, + samesite="none" if cross_site else "lax", + ) + + +def validate_callback( + request: Request, + token_data: OAuth2Token, + state: str | None, + *, + type: OAuthStateType, +) -> dict[str, Any]: + error_description = token_data.get("error_description") + if error_description: + raise OAuthCallbackError(error_description) + + if not state: + raise OAuthCallbackError("No state") + + session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) + if session_cookie is None or session_cookie != state: + raise OAuthCallbackError("Invalid session cookie") + + return decode_state(state, type=type) From 892e596a8ecd34727d264f964a11139b55ace40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Tue, 13 Jan 2026 09:30:23 +0100 Subject: [PATCH 5/8] server/oauth: use a nonce to secure OAuth login flow --- server/polar/config.py | 6 ++-- server/polar/integrations/apple/endpoints.py | 6 ++-- server/polar/integrations/github/endpoints.py | 6 ++-- server/polar/integrations/google/endpoints.py | 6 ++-- server/polar/kit/oauth.py | 35 +++++++++++++------ 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/server/polar/config.py b/server/polar/config.py index 362735e735..a5693c2ed0 100644 --- a/server/polar/config.py +++ b/server/polar/config.py @@ -122,9 +122,9 @@ class Settings(BaseSettings): LOGIN_CODE_TTL_SECONDS: int = 60 * 30 # 30 minutes LOGIN_CODE_LENGTH: int = 6 - # Social login session - SOCIAL_LOGIN_SESSION_TTL: timedelta = timedelta(minutes=10) - SOCIAL_LOGIN_SESSION_COOKIE_KEY: str = "polar_social_login_session" + # OAuth state + OAUTH_STATE_TTL: timedelta = timedelta(minutes=10) + OAUTH_STATE_COOKIE_KEY: str = "polar_oauth_state" # App Review bypass (for testing login flow during Apple/Google app reviews) APP_REVIEW_EMAIL: str | None = None diff --git a/server/polar/integrations/apple/endpoints.py b/server/polar/integrations/apple/endpoints.py index fdc4f3a523..4d9154a73b 100644 --- a/server/polar/integrations/apple/endpoints.py +++ b/server/polar/integrations/apple/endpoints.py @@ -16,7 +16,7 @@ from polar.kit.oauth import ( OAuthCallbackError, clear_login_cookie, - encode_state, + generate_state, set_login_cookie, validate_callback, ) @@ -49,7 +49,7 @@ async def apple_authorize( if signup_attribution: state["signup_attribution"] = signup_attribution.model_dump(exclude_unset=True) - encoded_state = encode_state(state, type="apple_oauth") + encoded_state, nonce = generate_state(state, type="apple_oauth") redirect_uri = str(request.url_for("integrations.apple.callback")) apple_oauth_client = get_apple_oauth_client() authorization_url = await apple_oauth_client.get_authorization_url( @@ -58,7 +58,7 @@ async def apple_authorize( extras_params={"response_mode": "form_post"}, ) response = RedirectResponse(authorization_url, 303) - set_login_cookie(request, response, encoded_state, cross_site=True) + set_login_cookie(request, response, nonce, cross_site=True) return response diff --git a/server/polar/integrations/github/endpoints.py b/server/polar/integrations/github/endpoints.py index 9c4c357cff..cbc1064ea7 100644 --- a/server/polar/integrations/github/endpoints.py +++ b/server/polar/integrations/github/endpoints.py @@ -17,7 +17,7 @@ from polar.kit.oauth import ( OAuthCallbackError, clear_login_cookie, - encode_state, + generate_state, set_login_cookie, validate_callback, ) @@ -55,7 +55,7 @@ async def create_authorization_response( state: dict[str, Any], callback_route: str, ) -> RedirectResponse: - encoded_state = encode_state(state, type="github_oauth") + encoded_state, nonce = generate_state(state, type="github_oauth") redirect_uri = str(request.url_for(callback_route)) authorization_url = await github_oauth_client.get_authorization_url( redirect_uri=redirect_uri, @@ -63,7 +63,7 @@ async def create_authorization_response( scope=GITHUB_OAUTH_SCOPES, ) response = RedirectResponse(authorization_url, 303) - set_login_cookie(request, response, encoded_state) + set_login_cookie(request, response, nonce) return response diff --git a/server/polar/integrations/google/endpoints.py b/server/polar/integrations/google/endpoints.py index 571741c770..2be4ce87e9 100644 --- a/server/polar/integrations/google/endpoints.py +++ b/server/polar/integrations/google/endpoints.py @@ -15,7 +15,7 @@ from polar.kit.oauth import ( OAuthCallbackError, clear_login_cookie, - encode_state, + generate_state, set_login_cookie, validate_callback, ) @@ -47,7 +47,7 @@ async def create_authorization_response( state: dict[str, Any], callback_route: str, ) -> RedirectResponse: - encoded_state = encode_state(state, type="google_oauth") + encoded_state, nonce = generate_state(state, type="google_oauth") redirect_uri = str(request.url_for(callback_route)) authorization_url = await google_oauth_client.get_authorization_url( redirect_uri=redirect_uri, @@ -55,7 +55,7 @@ async def create_authorization_response( scope=GOOGLE_OAUTH_SCOPES, ) response = RedirectResponse(authorization_url, 303) - set_login_cookie(request, response, encoded_state) + set_login_cookie(request, response, nonce) return response diff --git a/server/polar/kit/oauth.py b/server/polar/kit/oauth.py index a6d9881956..706f09083e 100644 --- a/server/polar/kit/oauth.py +++ b/server/polar/kit/oauth.py @@ -1,3 +1,4 @@ +import secrets from typing import Any, Literal from fastapi import Request @@ -16,11 +17,14 @@ class OAuthCallbackError(PolarRedirectionError): OAuthStateType = Literal["github_oauth", "google_oauth", "apple_oauth"] -def encode_state(state: dict[str, Any], *, type: OAuthStateType) -> str: - return jwt.encode(data=state, secret=settings.SECRET, type=type) +def generate_state(state: dict[str, Any], *, type: OAuthStateType) -> tuple[str, str]: + nonce = secrets.token_urlsafe() + state_with_nonce = {**state, "nonce": nonce} + encoded = jwt.encode(data=state_with_nonce, secret=settings.SECRET, type=type) + return encoded, nonce -def decode_state(state: str, *, type: OAuthStateType) -> dict[str, Any]: +def parse_state(state: str, *, type: OAuthStateType) -> dict[str, Any]: try: return jwt.decode(token=state, secret=settings.SECRET, type=type) except jwt.DecodeError as e: @@ -30,16 +34,16 @@ def decode_state(state: str, *, type: OAuthStateType) -> dict[str, Any]: def set_login_cookie( request: Request, response: RedirectResponse, - encoded_state: str, + nonce: str, *, cross_site: bool = False, ) -> None: is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} secure = False if is_localhost else True response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, - value=encoded_state, - max_age=int(settings.SOCIAL_LOGIN_SESSION_TTL.total_seconds()), + settings.OAUTH_STATE_COOKIE_KEY, + value=nonce, + max_age=int(settings.OAUTH_STATE_TTL.total_seconds()), path="/", secure=secure, httponly=True, @@ -56,7 +60,7 @@ def clear_login_cookie( is_localhost = request.url.hostname in {"127.0.0.1", "localhost"} secure = False if is_localhost else True response.set_cookie( - settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY, + settings.OAUTH_STATE_COOKIE_KEY, value="", max_age=0, path="/", @@ -80,8 +84,17 @@ def validate_callback( if not state: raise OAuthCallbackError("No state") - session_cookie = request.cookies.get(settings.SOCIAL_LOGIN_SESSION_COOKIE_KEY) - if session_cookie is None or session_cookie != state: + state_data = parse_state(state, type=type) + + state_nonce = state_data.get("nonce") + if not state_nonce or not isinstance(state_nonce, str): + raise OAuthCallbackError("Invalid state: missing nonce") + + cookie_nonce = request.cookies.get(settings.OAUTH_STATE_COOKIE_KEY) + if cookie_nonce is None: + raise OAuthCallbackError("Invalid session cookie") + + if not secrets.compare_digest(state_nonce, cookie_nonce): raise OAuthCallbackError("Invalid session cookie") - return decode_state(state, type=type) + return state_data From 8c92af83d1fe7770b5735c85a83cd7b039d5dc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Jan 2026 15:40:41 +0100 Subject: [PATCH 6/8] clients/packages/client: update OpenAPI client --- clients/packages/client/src/v1.ts | 695 +++++++++++++++++++++++++++++- 1 file changed, 676 insertions(+), 19 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index a7f13314c8..d307690164 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -119,15 +119,15 @@ export interface paths { patch?: never trace?: never } - '/v1/integrations/github/authorize': { + '/v1/integrations/github/login/authorize': { parameters: { query?: never header?: never path?: never cookie?: never } - /** Integrations.Github.Authorize */ - get: operations['integrations_github:integrations.github.authorize'] + /** Integrations.Github.Login.Authorize */ + get: operations['integrations_github:integrations_github_login:integrations.github.login.authorize'] put?: never post?: never delete?: never @@ -136,15 +136,49 @@ export interface paths { patch?: never trace?: never } - '/v1/integrations/github/callback': { + '/v1/integrations/github/login/callback': { parameters: { query?: never header?: never path?: never cookie?: never } - /** Integrations.Github.Callback */ - get: operations['integrations_github:integrations.github.callback'] + /** Integrations.Github.Login.Callback */ + get: operations['integrations_github:integrations_github_login:integrations.github.login.callback'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/v1/integrations/github/link/authorize': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Integrations.Github.Link.Authorize */ + get: operations['integrations_github:integrations_github_link:integrations.github.link.authorize'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/v1/integrations/github/link/callback': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Integrations.Github.Link.Callback */ + get: operations['integrations_github:integrations_github_link:integrations.github.link.callback'] put?: never post?: never delete?: never @@ -2008,15 +2042,49 @@ export interface paths { patch?: never trace?: never } - '/v1/integrations/google/authorize': { + '/v1/integrations/google/login/authorize': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Integrations.Google.Login.Authorize */ + get: operations['integrations_google:integrations_google_login:integrations.google.login.authorize'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/v1/integrations/google/login/callback': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Integrations.Google.Login.Callback */ + get: operations['integrations_google:integrations_google_login:integrations.google.login.callback'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/v1/integrations/google/link/authorize': { parameters: { query?: never header?: never path?: never cookie?: never } - /** Integrations.Google.Authorize */ - get: operations['integrations_google:integrations.google.authorize'] + /** Integrations.Google.Link.Authorize */ + get: operations['integrations_google:integrations_google_link:integrations.google.link.authorize'] put?: never post?: never delete?: never @@ -2025,15 +2093,15 @@ export interface paths { patch?: never trace?: never } - '/v1/integrations/google/callback': { + '/v1/integrations/google/link/callback': { parameters: { query?: never header?: never path?: never cookie?: never } - /** Integrations.Google.Callback */ - get: operations['integrations_google:integrations.google.callback'] + /** Integrations.Google.Link.Callback */ + get: operations['integrations_google:integrations_google_link:integrations.google.link.callback'] put?: never post?: never delete?: never @@ -5871,6 +5939,418 @@ export interface components { | 'notifications:write' | 'notification_recipients:read' | 'notification_recipients:write' + /** + * BalanceDisputeEvent + * @description An event created by Polar when an order is disputed. + */ + BalanceDisputeEvent: { + /** + * Id + * Format: uuid4 + * @description The ID of the object. + */ + id: string + /** + * Timestamp + * Format: date-time + * @description The timestamp of the event. + */ + timestamp: string + /** + * Organization Id + * Format: uuid4 + * @description The ID of the organization owning the event. + * @example 1dbfc517-0bbf-4301-9ba8-555ca42b9737 + */ + organization_id: string + /** + * Customer Id + * @description ID of the customer in your Polar organization associated with the event. + */ + customer_id: string | null + /** @description The customer associated with the event. */ + customer: components['schemas']['Customer'] | null + /** + * External Customer Id + * @description ID of the customer in your system associated with the event. + */ + external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number + /** + * Parent Id + * @description The ID of the parent event. + */ + parent_id?: string | null + /** + * Label + * @description Human readable label of the event type. + */ + label: string + /** + * Source + * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. + * @constant + */ + source: 'system' + /** + * @description The name of the event. (enum property replaced by openapi-typescript) + * @enum {string} + */ + name: 'balance.dispute' + metadata: components['schemas']['BalanceDisputeMetadata'] + } + /** BalanceDisputeMetadata */ + BalanceDisputeMetadata: { + /** Transaction Id */ + transaction_id: string + /** Dispute Id */ + dispute_id: string + /** Order Id */ + order_id?: string + /** Product Id */ + product_id?: string + /** Subscription Id */ + subscription_id?: string + /** Amount */ + amount: number + /** Currency */ + currency: string + /** Presentment Amount */ + presentment_amount: number + /** Presentment Currency */ + presentment_currency: string + /** Tax Amount */ + tax_amount: number + /** Tax State */ + tax_state?: string | null + /** Tax Country */ + tax_country?: string | null + /** Fee */ + fee: number + } + /** + * BalanceDisputeReversalEvent + * @description An event created by Polar when a dispute is won and funds are reinstated. + */ + BalanceDisputeReversalEvent: { + /** + * Id + * Format: uuid4 + * @description The ID of the object. + */ + id: string + /** + * Timestamp + * Format: date-time + * @description The timestamp of the event. + */ + timestamp: string + /** + * Organization Id + * Format: uuid4 + * @description The ID of the organization owning the event. + * @example 1dbfc517-0bbf-4301-9ba8-555ca42b9737 + */ + organization_id: string + /** + * Customer Id + * @description ID of the customer in your Polar organization associated with the event. + */ + customer_id: string | null + /** @description The customer associated with the event. */ + customer: components['schemas']['Customer'] | null + /** + * External Customer Id + * @description ID of the customer in your system associated with the event. + */ + external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number + /** + * Parent Id + * @description The ID of the parent event. + */ + parent_id?: string | null + /** + * Label + * @description Human readable label of the event type. + */ + label: string + /** + * Source + * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. + * @constant + */ + source: 'system' + /** + * @description The name of the event. (enum property replaced by openapi-typescript) + * @enum {string} + */ + name: 'balance.dispute_reversal' + metadata: components['schemas']['BalanceDisputeMetadata'] + } + /** + * BalanceOrderEvent + * @description An event created by Polar when an order is paid. + */ + BalanceOrderEvent: { + /** + * Id + * Format: uuid4 + * @description The ID of the object. + */ + id: string + /** + * Timestamp + * Format: date-time + * @description The timestamp of the event. + */ + timestamp: string + /** + * Organization Id + * Format: uuid4 + * @description The ID of the organization owning the event. + * @example 1dbfc517-0bbf-4301-9ba8-555ca42b9737 + */ + organization_id: string + /** + * Customer Id + * @description ID of the customer in your Polar organization associated with the event. + */ + customer_id: string | null + /** @description The customer associated with the event. */ + customer: components['schemas']['Customer'] | null + /** + * External Customer Id + * @description ID of the customer in your system associated with the event. + */ + external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number + /** + * Parent Id + * @description The ID of the parent event. + */ + parent_id?: string | null + /** + * Label + * @description Human readable label of the event type. + */ + label: string + /** + * Source + * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. + * @constant + */ + source: 'system' + /** + * @description The name of the event. (enum property replaced by openapi-typescript) + * @enum {string} + */ + name: 'balance.order' + metadata: components['schemas']['BalanceOrderMetadata'] + } + /** BalanceOrderMetadata */ + BalanceOrderMetadata: { + /** Transaction Id */ + transaction_id: string + /** Order Id */ + order_id: string + /** Product Id */ + product_id?: string + /** Subscription Id */ + subscription_id?: string + /** Amount */ + amount: number + /** Currency */ + currency: string + /** Presentment Amount */ + presentment_amount: number + /** Presentment Currency */ + presentment_currency: string + /** Tax Amount */ + tax_amount: number + /** Tax State */ + tax_state?: string | null + /** Tax Country */ + tax_country?: string | null + /** Fee */ + fee: number + } + /** + * BalanceRefundEvent + * @description An event created by Polar when an order is refunded. + */ + BalanceRefundEvent: { + /** + * Id + * Format: uuid4 + * @description The ID of the object. + */ + id: string + /** + * Timestamp + * Format: date-time + * @description The timestamp of the event. + */ + timestamp: string + /** + * Organization Id + * Format: uuid4 + * @description The ID of the organization owning the event. + * @example 1dbfc517-0bbf-4301-9ba8-555ca42b9737 + */ + organization_id: string + /** + * Customer Id + * @description ID of the customer in your Polar organization associated with the event. + */ + customer_id: string | null + /** @description The customer associated with the event. */ + customer: components['schemas']['Customer'] | null + /** + * External Customer Id + * @description ID of the customer in your system associated with the event. + */ + external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number + /** + * Parent Id + * @description The ID of the parent event. + */ + parent_id?: string | null + /** + * Label + * @description Human readable label of the event type. + */ + label: string + /** + * Source + * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. + * @constant + */ + source: 'system' + /** + * @description The name of the event. (enum property replaced by openapi-typescript) + * @enum {string} + */ + name: 'balance.refund' + metadata: components['schemas']['BalanceRefundMetadata'] + } + /** BalanceRefundMetadata */ + BalanceRefundMetadata: { + /** Transaction Id */ + transaction_id: string + /** Refund Id */ + refund_id: string + /** Order Id */ + order_id?: string + /** Product Id */ + product_id?: string + /** Subscription Id */ + subscription_id?: string + /** Amount */ + amount: number + /** Currency */ + currency: string + /** Presentment Amount */ + presentment_amount: number + /** Presentment Currency */ + presentment_currency: string + /** Refundable Amount */ + refundable_amount?: number + /** Tax Amount */ + tax_amount: number + /** Tax State */ + tax_state?: string | null + /** Tax Country */ + tax_country?: string | null + /** Fee */ + fee: number + } + /** + * BalanceRefundReversalEvent + * @description An event created by Polar when a refund is reverted. + */ + BalanceRefundReversalEvent: { + /** + * Id + * Format: uuid4 + * @description The ID of the object. + */ + id: string + /** + * Timestamp + * Format: date-time + * @description The timestamp of the event. + */ + timestamp: string + /** + * Organization Id + * Format: uuid4 + * @description The ID of the organization owning the event. + * @example 1dbfc517-0bbf-4301-9ba8-555ca42b9737 + */ + organization_id: string + /** + * Customer Id + * @description ID of the customer in your Polar organization associated with the event. + */ + customer_id: string | null + /** @description The customer associated with the event. */ + customer: components['schemas']['Customer'] | null + /** + * External Customer Id + * @description ID of the customer in your system associated with the event. + */ + external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number + /** + * Parent Id + * @description The ID of the parent event. + */ + parent_id?: string | null + /** + * Label + * @description Human readable label of the event type. + */ + label: string + /** + * Source + * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. + * @constant + */ + source: 'system' + /** + * @description The name of the event. (enum property replaced by openapi-typescript) + * @enum {string} + */ + name: 'balance.refund_reversal' + metadata: components['schemas']['BalanceRefundMetadata'] + } Benefit: | components['schemas']['BenefitCustom'] | components['schemas']['BenefitDiscord'] @@ -9755,6 +10235,7 @@ export interface components { * @description If you plan to embed the checkout session, set this to the Origin of the embedding page. It'll allow the Polar iframe to communicate with the parent page. */ embed_origin?: string | null + currency?: components['schemas']['PresentmentCurrency'] | null /** * Product Id * Format: uuid4 @@ -9896,6 +10377,7 @@ export interface components { * @description If you plan to embed the checkout session, set this to the Origin of the embedding page. It'll allow the Polar iframe to communicate with the parent page. */ embed_origin?: string | null + currency?: components['schemas']['PresentmentCurrency'] | null /** * Products * @description List of product IDs available to select at that checkout. The first one will be selected by default. @@ -10514,6 +10996,7 @@ export interface components { metadata?: { [key: string]: string | number | boolean } + currency?: components['schemas']['PresentmentCurrency'] | null /** * Discount Id * @description ID of the discount to apply to the checkout. @@ -13461,9 +13944,19 @@ export interface components { status: components['schemas']['SeatStatus'] /** * Customer Id - * @description The assigned customer ID + * @description The customer ID. When member_model_enabled is true, this is the billing customer (purchaser). When false, this is the seat member customer. */ customer_id?: string | null + /** + * Member Id + * @description The member ID of the seat occupant + */ + member_id?: string | null + /** + * Email + * @description Email of the seat member (set when member_model_enabled is true) + */ + email?: string | null /** * Customer Email * @description The assigned customer email @@ -17508,8 +18001,6 @@ export interface components { organization_name: string /** Amount */ amount: number - /** Title */ - title: string /** Formatted Amount */ readonly formatted_amount: string } @@ -20511,6 +21002,19 @@ export interface components { | 'disputed' | 'charge_disputed' | 'cancelled' + /** + * PresentmentCurrency + * @enum {string} + */ + PresentmentCurrency: + | 'usd' + | 'eur' + | 'gbp' + | 'cad' + | 'aud' + | 'jpy' + | 'chf' + | 'sek' /** * Processor * @description Supported payment or payout processors, i.e rails for transactions. @@ -23546,6 +24050,11 @@ export interface components { | components['schemas']['CustomerCreatedEvent'] | components['schemas']['CustomerUpdatedEvent'] | components['schemas']['CustomerDeletedEvent'] + | components['schemas']['BalanceOrderEvent'] + | components['schemas']['BalanceRefundEvent'] + | components['schemas']['BalanceRefundReversalEvent'] + | components['schemas']['BalanceDisputeEvent'] + | components['schemas']['BalanceDisputeReversalEvent'] /** * TaxIDFormat * @description List of supported tax ID formats. @@ -25530,7 +26039,7 @@ export interface operations { } } } - 'integrations_github:integrations.github.authorize': { + 'integrations_github:integrations_github_login:integrations.github.login.authorize': { parameters: { query?: { payment_intent_id?: string | null @@ -25563,7 +26072,72 @@ export interface operations { } } } - 'integrations_github:integrations.github.callback': { + 'integrations_github:integrations_github_login:integrations.github.login.callback': { + parameters: { + query?: { + code?: string | null + code_verifier?: string | null + state?: string | null + error?: string | null + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': unknown + } + } + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HTTPValidationError'] + } + } + } + } + 'integrations_github:integrations_github_link:integrations.github.link.authorize': { + parameters: { + query?: { + return_to?: string | null + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': unknown + } + } + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HTTPValidationError'] + } + } + } + } + 'integrations_github:integrations_github_link:integrations.github.link.callback': { parameters: { query?: { code?: string | null @@ -30390,7 +30964,7 @@ export interface operations { } } } - 'integrations_google:integrations.google.authorize': { + 'integrations_google:integrations_google_login:integrations.google.login.authorize': { parameters: { query?: { return_to?: string | null @@ -30422,7 +30996,72 @@ export interface operations { } } } - 'integrations_google:integrations.google.callback': { + 'integrations_google:integrations_google_login:integrations.google.login.callback': { + parameters: { + query?: { + code?: string | null + code_verifier?: string | null + state?: string | null + error?: string | null + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': unknown + } + } + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HTTPValidationError'] + } + } + } + } + 'integrations_google:integrations_google_link:integrations.google.link.authorize': { + parameters: { + query?: { + return_to?: string | null + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': unknown + } + } + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HTTPValidationError'] + } + } + } + } + 'integrations_google:integrations_google_link:integrations.google.link.callback': { parameters: { query?: { code?: string | null @@ -39019,6 +39658,21 @@ export const availableScopeValues: ReadonlyArray< 'notification_recipients:read', 'notification_recipients:write', ] +export const balanceDisputeEventNameValues: ReadonlyArray< + components['schemas']['BalanceDisputeEvent']['name'] +> = ['balance.dispute'] +export const balanceDisputeReversalEventNameValues: ReadonlyArray< + components['schemas']['BalanceDisputeReversalEvent']['name'] +> = ['balance.dispute_reversal'] +export const balanceOrderEventNameValues: ReadonlyArray< + components['schemas']['BalanceOrderEvent']['name'] +> = ['balance.order'] +export const balanceRefundEventNameValues: ReadonlyArray< + components['schemas']['BalanceRefundEvent']['name'] +> = ['balance.refund'] +export const balanceRefundReversalEventNameValues: ReadonlyArray< + components['schemas']['BalanceRefundReversalEvent']['name'] +> = ['balance.refund_reversal'] export const benefitCustomCreateTypeValues: ReadonlyArray< components['schemas']['BenefitCustomCreate']['type'] > = ['custom'] @@ -40170,6 +40824,9 @@ export const pledgeStateValues: ReadonlyArray< 'charge_disputed', 'cancelled', ] +export const presentmentCurrencyValues: ReadonlyArray< + components['schemas']['PresentmentCurrency'] +> = ['usd', 'eur', 'gbp', 'cad', 'aud', 'jpy', 'chf', 'sek'] export const processorValues: ReadonlyArray< components['schemas']['Processor'] > = ['stripe', 'manual', 'open_collective'] From bad5952f1122d9b40a0c01b3a3f192c2aac339b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Jan 2026 15:41:23 +0100 Subject: [PATCH 7/8] clients/web: use login/link flow accordingly on Google OAuth --- .../src/components/Auth/GoogleLoginButton.tsx | 4 ++-- .../Settings/AuthenticationSettings.tsx | 4 ++-- clients/apps/web/src/utils/auth.ts | 18 +++++++++++++++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/clients/apps/web/src/components/Auth/GoogleLoginButton.tsx b/clients/apps/web/src/components/Auth/GoogleLoginButton.tsx index 31691fde61..777f8ed50d 100644 --- a/clients/apps/web/src/components/Auth/GoogleLoginButton.tsx +++ b/clients/apps/web/src/components/Auth/GoogleLoginButton.tsx @@ -1,5 +1,5 @@ import { usePostHog, type EventName } from '@/hooks/posthog' -import { getGoogleAuthorizeURL } from '@/utils/auth' +import { getGoogleAuthorizeLoginURL } from '@/utils/auth' import Google from '@mui/icons-material/Google' import { schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' @@ -26,7 +26,7 @@ const GoogleLoginButton = ({ returnTo, signup }: GoogleLoginButtonProps) => { return ( = ({ onDisconnect, isDisconnecting, }) => { - const authorizeURL = getGoogleAuthorizeURL({ return_to: returnTo }) + const authorizeURL = getGoogleAuthorizeLinkURL({ return_to: returnTo }) return ( , ): string => { const searchParams = new URLSearchParams() @@ -28,7 +28,19 @@ export const getGoogleAuthorizeURL = ( if (params.attribution) { searchParams.set('attribution', params.attribution) } - return `${getPublicServerURL()}/v1/integrations/google/authorize?${searchParams}` + return `${getPublicServerURL()}/v1/integrations/google/login/authorize?${searchParams}` +} + +export const getGoogleAuthorizeLinkURL = ( + params: NonNullable< + operations['integrations_google:integrations_google_link:integrations.google.link.authorize']['parameters']['query'] + >, +): string => { + const searchParams = new URLSearchParams() + if (params.return_to) { + searchParams.set('return_to', params.return_to) + } + return `${getPublicServerURL()}/v1/integrations/google/link/authorize?${searchParams}` } export const getAppleAuthorizeURL = ( From 7c339e1c6615c680ded008b829acf440d8307cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 12 Jan 2026 15:56:57 +0100 Subject: [PATCH 8/8] clients/web: use login/link flow accordingly on GitHub OAuth --- .../src/components/Auth/GithubLoginButton.tsx | 4 ++-- .../Settings/AuthenticationSettings.tsx | 7 +++++-- clients/apps/web/src/utils/auth.ts | 18 +++++++++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/clients/apps/web/src/components/Auth/GithubLoginButton.tsx b/clients/apps/web/src/components/Auth/GithubLoginButton.tsx index 7d7934a079..6aa4240001 100644 --- a/clients/apps/web/src/components/Auth/GithubLoginButton.tsx +++ b/clients/apps/web/src/components/Auth/GithubLoginButton.tsx @@ -1,7 +1,7 @@ 'use client' import { usePostHog, type EventName } from '@/hooks/posthog' -import { getGitHubAuthorizeURL } from '@/utils/auth' +import { getGitHubAuthorizeLoginURL } from '@/utils/auth' import { schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' import Link from 'next/link' @@ -18,7 +18,7 @@ const GithubLoginButton = (props: { const posthog = usePostHog() const signup = props.signup - const authorizeURL = getGitHubAuthorizeURL({ + const authorizeURL = getGitHubAuthorizeLoginURL({ return_to: props.returnTo, attribution: JSON.stringify(signup), }) diff --git a/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx b/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx index 19d99d62af..148e2b27eb 100644 --- a/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx +++ b/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx @@ -6,7 +6,10 @@ import { useGitHubAccount, useGoogleAccount, } from '@/hooks' -import { getGitHubAuthorizeURL, getGoogleAuthorizeLinkURL } from '@/utils/auth' +import { + getGitHubAuthorizeLinkURL, + getGoogleAuthorizeLinkURL, +} from '@/utils/auth' import AlternateEmailOutlined from '@mui/icons-material/AlternateEmailOutlined' import GitHub from '@mui/icons-material/GitHub' import Google from '@mui/icons-material/Google' @@ -56,7 +59,7 @@ const GitHubAuthenticationMethod: React.FC = ({ onDisconnect, isDisconnecting, }) => { - const authorizeURL = getGitHubAuthorizeURL({ return_to: returnTo }) + const authorizeURL = getGitHubAuthorizeLinkURL({ return_to: returnTo }) return ( , ): string => { const searchParams = new URLSearchParams() @@ -13,7 +13,19 @@ export const getGitHubAuthorizeURL = ( if (params.attribution) { searchParams.set('attribution', params.attribution) } - return `${getPublicServerURL()}/v1/integrations/github/authorize?${searchParams}` + return `${getPublicServerURL()}/v1/integrations/github/login/authorize?${searchParams}` +} + +export const getGitHubAuthorizeLinkURL = ( + params: NonNullable< + operations['integrations_github:integrations_github_link:integrations.github.link.authorize']['parameters']['query'] + >, +): string => { + const searchParams = new URLSearchParams() + if (params.return_to) { + searchParams.set('return_to', params.return_to) + } + return `${getPublicServerURL()}/v1/integrations/github/link/authorize?${searchParams}` } export const getGoogleAuthorizeLoginURL = (