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
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,30 @@ class AuthGoogleGetAuthorisationUrlArgs:
"""Arguments for building a Google OAuth authorisation URL.

Attributes:
callback_uri (str): A system URL that may point at localhost.
ready_url (str): A system URL that may point at localhost.
callback_success_url (str): A system URL that may point at localhost.
callback_failure_url (str): A system URL that may point at localhost.
"""

callback_uri: str
ready_url: str
callback_success_url: str
callback_failure_url: str
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)

def to_dict(self) -> dict[str, Any]:
callback_uri = self.callback_uri
ready_url = self.ready_url

callback_success_url = self.callback_success_url

callback_failure_url = self.callback_failure_url

field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"callback_uri": callback_uri,
"ready_url": ready_url,
"callback_success_url": callback_success_url,
"callback_failure_url": callback_failure_url,
}
)

Expand All @@ -36,10 +46,16 @@ def to_dict(self) -> dict[str, Any]:
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
d = dict(src_dict)
callback_uri = d.pop("callback_uri")
ready_url = d.pop("ready_url")

callback_success_url = d.pop("callback_success_url")

callback_failure_url = d.pop("callback_failure_url")

auth_google_get_authorisation_url_args = cls(
callback_uri=callback_uri,
ready_url=ready_url,
callback_success_url=callback_success_url,
callback_failure_url=callback_failure_url,
)

auth_google_get_authorisation_url_args.additional_properties = d
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { SystemUrl } from './SystemUrl';
* Arguments for building a Google OAuth authorisation URL.
*/
export type AuthGoogleGetAuthorisationUrlArgs = {
callback_uri: SystemUrl;
ready_url: SystemUrl;
callback_success_url: SystemUrl;
callback_failure_url: SystemUrl;
};

2 changes: 2 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ envVarGroups:
value: local-google-apple
- key: EMAIL_VERIFICATION_STRATEGY
value: verify
previewValue: none
- key: TELEMETRY
value: sentry
previewValue: sentry
Expand Down Expand Up @@ -124,6 +125,7 @@ services:
value: local-google-apple
- key: EMAIL_VERIFICATION_STRATEGY
value: verify
previewValue: none
- type: web
name: jupiter-webapi-srv
buildFilter:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import { GlobalPropertiesContext } from "#/core/config-client";

const GOOGLE_PREPARE_LINK = "/app/lifecycle/init/google/prepare";

export function LifecycleOAuthProviderButtons() {
interface LifecycleOAuthProviderButtonsProps {
disabled?: boolean;
}

export function LifecycleOAuthProviderButtons({
disabled = false,
}: LifecycleOAuthProviderButtonsProps) {
const globalProperties = useContext(GlobalPropertiesContext);

if (
Expand All @@ -24,6 +30,7 @@ export function LifecycleOAuthProviderButtons() {
to={GOOGLE_PREPARE_LINK}
variant="outlined"
fullWidth
disabled={disabled}
startIcon={<GoogleIcon />}
>
Google
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { GLOBAL_PROPERTIES, SERVICE_PROPERTIES } from "#/core/config-server";
import { isLocal } from "#/core/env";

export interface GoogleOauthRedirectState {
callbackSuccessUrl: string;
callbackFailureUrl: string;
}

function hostnameForUrl(url: string): string | null {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return null;
}
}

function hostnameMatchesInfraRoot(
hostname: string,
infraRoot: string,
): boolean {
const normalizedRoot = infraRoot.toLowerCase();
return hostname === normalizedRoot || hostname.endsWith(`.${normalizedRoot}`);
}

export function isAllowedGoogleOauthCallbackUrl(url: string): boolean {
const hostname = hostnameForUrl(url);
if (hostname === null) {
return false;
}

const hostedWebUiHostname = hostnameForUrl(
GLOBAL_PROPERTIES.hostedGlobalWebUiUrl,
);
if (hostedWebUiHostname !== null && hostname === hostedWebUiHostname) {
return true;
}

if (
hostnameMatchesInfraRoot(hostname, GLOBAL_PROPERTIES.globalHostedInfraRoot)
) {
return true;
}

if (isLocal(GLOBAL_PROPERTIES.env)) {
const localWebUiHostname = hostnameForUrl(SERVICE_PROPERTIES.webUiUrl);
if (localWebUiHostname !== null && hostname === localWebUiHostname) {
return true;
}
}

return false;
}

export function decodeGoogleOauthRedirectState(
state: string,
): GoogleOauthRedirectState | null {
try {
const padding = "=".repeat((4 - (state.length % 4)) % 4);
const raw = Buffer.from(`${state}${padding}`, "base64url").toString(
"utf-8",
);
const payload = JSON.parse(raw) as {
v?: unknown;
callback_success_url?: unknown;
callback_failure_url?: unknown;
};

if (
payload.v !== 1 ||
typeof payload.callback_success_url !== "string" ||
typeof payload.callback_failure_url !== "string"
) {
return null;
}

return {
callbackSuccessUrl: payload.callback_success_url,
callbackFailureUrl: payload.callback_failure_url,
};
} catch {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""OAuth state payload for Google login via a hosted redirect URL."""

import base64
import json
import secrets
from typing import Final

from jupiter.core.common.system_url import SystemUrl
from jupiter.framework.realm.realm import (
RealmDecoder,
RealmDecodingError,
RealmEncoder,
RealmThing,
WebRealm,
only_in_realm,
)
from jupiter.framework.value import CompositeValue, value

_STATE_VERSION: Final[int] = 1


@value
@only_in_realm(WebRealm)
class GoogleOauthRedirectState(CompositeValue):
"""OAuth state embedding post-auth redirect targets."""

nonce: str
callback_success_url: SystemUrl
callback_failure_url: SystemUrl

@staticmethod
def new(
callback_success_url: SystemUrl,
callback_failure_url: SystemUrl,
) -> "GoogleOauthRedirectState":
"""Build a fresh OAuth state value."""
return GoogleOauthRedirectState(
nonce=secrets.token_urlsafe(16),
callback_success_url=callback_success_url,
callback_failure_url=callback_failure_url,
)


class GoogleOauthRedirectStateWebEncoder(
RealmEncoder[GoogleOauthRedirectState, WebRealm]
):
"""Encode OAuth redirect state for the Google authorisation URL."""

def encode(self, value: GoogleOauthRedirectState) -> RealmThing:
"""Encode to a base64url JSON string."""
payload = {
"v": _STATE_VERSION,
"nonce": value.nonce,
"callback_success_url": value.callback_success_url.the_url,
"callback_failure_url": value.callback_failure_url.the_url,
}
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
return encoded.rstrip("=")


class GoogleOauthRedirectStateWebDecoder(
RealmDecoder[GoogleOauthRedirectState, WebRealm]
):
"""Decode OAuth redirect state from the Google authorisation URL."""

def decode(self, value: RealmThing) -> GoogleOauthRedirectState:
"""Decode from a base64url JSON string."""
if not isinstance(value, str):
raise RealmDecodingError("Expected Google OAuth state to be a string")

padding = "=" * (-len(value) % 4)
try:
raw = base64.urlsafe_b64decode(f"{value}{padding}")
payload = json.loads(raw)
except (ValueError, json.JSONDecodeError) as err:
raise RealmDecodingError("Invalid Google OAuth state") from err

if not isinstance(payload, dict):
raise RealmDecodingError("Invalid Google OAuth state")

version = payload.get("v")
nonce = payload.get("nonce")
callback_success_url = payload.get("callback_success_url")
callback_failure_url = payload.get("callback_failure_url")

if (
version != _STATE_VERSION
or not isinstance(nonce, str)
or not isinstance(callback_success_url, str)
or not isinstance(callback_failure_url, str)
):
raise RealmDecodingError("Invalid Google OAuth state")

return GoogleOauthRedirectState(
nonce=nonce,
callback_success_url=SystemUrl(callback_success_url),
callback_failure_url=SystemUrl(callback_failure_url),
)
26 changes: 23 additions & 3 deletions src/core/jupiter/core/auth/sub/google/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from authlib.integrations.base_client.errors import OAuthError
from authlib.integrations.httpx_client import AsyncOAuth2Client
from jupiter.core.auth.sub.google.google_auth_code import GoogleAuthCode
from jupiter.core.auth.sub.google.google_oauth_redirect_state import (
GoogleOauthRedirectState,
)
from jupiter.core.auth.sub.google.id_token_claims import GoogleIdTokenClaims
from jupiter.core.auth.sub.google.oauth_token_response import GoogleOAuthTokenResponse
from jupiter.core.auth.sub.google.refresh_token_encrypted import (
Expand Down Expand Up @@ -66,14 +69,31 @@ def __init__(
client_secret=self._client_secret,
)

def get_authorisation_url(self, callback_uri: SystemUrl) -> tuple[URL, str]:
def get_authorisation_url(
self,
ready_url: SystemUrl,
callback_success_url: SystemUrl,
callback_failure_url: SystemUrl,
) -> tuple[URL, str]:
"""Get the authorisation url and OAuth state."""
authorisation_url, state = self._client.create_authorization_url(
state = cast(
str,
self._realm_codec_registry.get_encoder(
GoogleOauthRedirectState, WebRealm
).encode(
GoogleOauthRedirectState.new(
callback_success_url,
callback_failure_url,
)
),
)
authorisation_url, _ = self._client.create_authorization_url(
_GOOGLE_AUTHORISATION_URL,
scope="openid email profile",
access_type="offline",
prompt="consent",
redirect_uri=callback_uri.the_url,
redirect_uri=ready_url.the_url,
state=state,
)
return URL(authorisation_url), state

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
class AuthGoogleGetAuthorisationUrlArgs(UseCaseArgsBase):
"""Arguments for building a Google OAuth authorisation URL."""

callback_uri: SystemUrl
ready_url: SystemUrl
callback_success_url: SystemUrl
callback_failure_url: SystemUrl


@use_case_result
Expand Down Expand Up @@ -54,7 +56,11 @@ async def _execute(
if self._ports.google_oauth_client is None:
raise RuntimeError("Google OAuth client is not configured")
authorisation_url, state = (
self._ports.google_oauth_client.get_authorisation_url(args.callback_uri)
self._ports.google_oauth_client.get_authorisation_url(
args.ready_url,
args.callback_success_url,
args.callback_failure_url,
)
)
return AuthGoogleGetAuthorisationUrlResult(
authorisation_url=authorisation_url,
Expand Down
2 changes: 2 additions & 0 deletions src/core/jupiter/core/config-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface GlobalPropertiesServer {
telemetry: JupiterTelemetry;
crmBackend: JupiterCrmBackend;
hostedGlobalWebUiUrl: string;
globalHostedInfraRoot: string;
communityUrl: string;
appsStorageUrl: string;
macStoreUrl: string;
Expand Down Expand Up @@ -72,6 +73,7 @@ function loadGlobalPropertiesOnServer(): GlobalPropertiesServer {
telemetry: (process.env.TELEMETRY ?? "local") as JupiterTelemetry,
crmBackend: (process.env.CRM ?? "noop") as JupiterCrmBackend,
hostedGlobalWebUiUrl: process.env.HOSTED_GLOBAL_WEBUI_URL as string,
globalHostedInfraRoot: process.env.GLOBAL_HOSTED_INFRA_ROOT as string,
communityUrl: process.env.COMMUNITY_URL as string,
appsStorageUrl: process.env.APPS_STORAGE_URL as string,
macStoreUrl: process.env.MAC_STORE_URL as string,
Expand Down
Loading
Loading