Skip to content

Commit 5651a74

Browse files
committed
Make auth work on other subdomains
1 parent fa8bed5 commit 5651a74

14 files changed

Lines changed: 367 additions & 30 deletions

File tree

gen/py/webapi-client/jupiter_webapi_client/models/auth_google_get_authorisation_url_args.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,30 @@ class AuthGoogleGetAuthorisationUrlArgs:
1414
"""Arguments for building a Google OAuth authorisation URL.
1515
1616
Attributes:
17-
callback_uri (str): A system URL that may point at localhost.
17+
ready_url (str): A system URL that may point at localhost.
18+
callback_success_url (str): A system URL that may point at localhost.
19+
callback_failure_url (str): A system URL that may point at localhost.
1820
"""
1921

20-
callback_uri: str
22+
ready_url: str
23+
callback_success_url: str
24+
callback_failure_url: str
2125
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
2226

2327
def to_dict(self) -> dict[str, Any]:
24-
callback_uri = self.callback_uri
28+
ready_url = self.ready_url
29+
30+
callback_success_url = self.callback_success_url
31+
32+
callback_failure_url = self.callback_failure_url
2533

2634
field_dict: dict[str, Any] = {}
2735
field_dict.update(self.additional_properties)
2836
field_dict.update(
2937
{
30-
"callback_uri": callback_uri,
38+
"ready_url": ready_url,
39+
"callback_success_url": callback_success_url,
40+
"callback_failure_url": callback_failure_url,
3141
}
3242
)
3343

@@ -36,10 +46,16 @@ def to_dict(self) -> dict[str, Any]:
3646
@classmethod
3747
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
3848
d = dict(src_dict)
39-
callback_uri = d.pop("callback_uri")
49+
ready_url = d.pop("ready_url")
50+
51+
callback_success_url = d.pop("callback_success_url")
52+
53+
callback_failure_url = d.pop("callback_failure_url")
4054

4155
auth_google_get_authorisation_url_args = cls(
42-
callback_uri=callback_uri,
56+
ready_url=ready_url,
57+
callback_success_url=callback_success_url,
58+
callback_failure_url=callback_failure_url,
4359
)
4460

4561
auth_google_get_authorisation_url_args.additional_properties = d

gen/ts/webapi-client/gen/models/AuthGoogleGetAuthorisationUrlArgs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { SystemUrl } from './SystemUrl';
77
* Arguments for building a Google OAuth authorisation URL.
88
*/
99
export type AuthGoogleGetAuthorisationUrlArgs = {
10-
callback_uri: SystemUrl;
10+
ready_url: SystemUrl;
11+
callback_success_url: SystemUrl;
12+
callback_failure_url: SystemUrl;
1113
};
1214

src/core/jupiter/core/auth/component/lifecycle-oauth-provider-buttons.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { GlobalPropertiesContext } from "#/core/config-client";
88

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

11-
export function LifecycleOAuthProviderButtons() {
11+
interface LifecycleOAuthProviderButtonsProps {
12+
disabled?: boolean;
13+
}
14+
15+
export function LifecycleOAuthProviderButtons({
16+
disabled = false,
17+
}: LifecycleOAuthProviderButtonsProps) {
1218
const globalProperties = useContext(GlobalPropertiesContext);
1319

1420
if (
@@ -24,6 +30,7 @@ export function LifecycleOAuthProviderButtons() {
2430
to={GOOGLE_PREPARE_LINK}
2531
variant="outlined"
2632
fullWidth
33+
disabled={disabled}
2734
startIcon={<GoogleIcon />}
2835
>
2936
Google
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { GLOBAL_PROPERTIES, SERVICE_PROPERTIES } from "#/core/config-server";
2+
import { isLocal } from "#/core/env";
3+
4+
export interface GoogleOauthRedirectState {
5+
callbackSuccessUrl: string;
6+
callbackFailureUrl: string;
7+
}
8+
9+
function hostnameForUrl(url: string): string | null {
10+
try {
11+
return new URL(url).hostname.toLowerCase();
12+
} catch {
13+
return null;
14+
}
15+
}
16+
17+
function hostnameMatchesInfraRoot(
18+
hostname: string,
19+
infraRoot: string,
20+
): boolean {
21+
const normalizedRoot = infraRoot.toLowerCase();
22+
return hostname === normalizedRoot || hostname.endsWith(`.${normalizedRoot}`);
23+
}
24+
25+
export function isAllowedGoogleOauthCallbackUrl(url: string): boolean {
26+
const hostname = hostnameForUrl(url);
27+
if (hostname === null) {
28+
return false;
29+
}
30+
31+
const hostedWebUiHostname = hostnameForUrl(
32+
GLOBAL_PROPERTIES.hostedGlobalWebUiUrl,
33+
);
34+
if (hostedWebUiHostname !== null && hostname === hostedWebUiHostname) {
35+
return true;
36+
}
37+
38+
if (
39+
hostnameMatchesInfraRoot(hostname, GLOBAL_PROPERTIES.globalHostedInfraRoot)
40+
) {
41+
return true;
42+
}
43+
44+
if (isLocal(GLOBAL_PROPERTIES.env)) {
45+
const localWebUiHostname = hostnameForUrl(SERVICE_PROPERTIES.webUiUrl);
46+
if (localWebUiHostname !== null && hostname === localWebUiHostname) {
47+
return true;
48+
}
49+
}
50+
51+
return false;
52+
}
53+
54+
export function decodeGoogleOauthRedirectState(
55+
state: string,
56+
): GoogleOauthRedirectState | null {
57+
try {
58+
const padding = "=".repeat((4 - (state.length % 4)) % 4);
59+
const raw = Buffer.from(`${state}${padding}`, "base64url").toString(
60+
"utf-8",
61+
);
62+
const payload = JSON.parse(raw) as {
63+
v?: unknown;
64+
callback_success_url?: unknown;
65+
callback_failure_url?: unknown;
66+
};
67+
68+
if (
69+
payload.v !== 1 ||
70+
typeof payload.callback_success_url !== "string" ||
71+
typeof payload.callback_failure_url !== "string"
72+
) {
73+
return null;
74+
}
75+
76+
return {
77+
callbackSuccessUrl: payload.callback_success_url,
78+
callbackFailureUrl: payload.callback_failure_url,
79+
};
80+
} catch {
81+
return null;
82+
}
83+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""OAuth state payload for Google login via a hosted redirect URL."""
2+
3+
import base64
4+
import json
5+
import secrets
6+
from typing import Final
7+
8+
from jupiter.core.common.system_url import SystemUrl
9+
from jupiter.framework.realm.realm import (
10+
RealmDecoder,
11+
RealmDecodingError,
12+
RealmEncoder,
13+
RealmThing,
14+
WebRealm,
15+
only_in_realm,
16+
)
17+
from jupiter.framework.value import CompositeValue, value
18+
19+
_STATE_VERSION: Final[int] = 1
20+
21+
22+
@value
23+
@only_in_realm(WebRealm)
24+
class GoogleOauthRedirectState(CompositeValue):
25+
"""OAuth state embedding post-auth redirect targets."""
26+
27+
nonce: str
28+
callback_success_url: SystemUrl
29+
callback_failure_url: SystemUrl
30+
31+
@staticmethod
32+
def new(
33+
callback_success_url: SystemUrl,
34+
callback_failure_url: SystemUrl,
35+
) -> "GoogleOauthRedirectState":
36+
"""Build a fresh OAuth state value."""
37+
return GoogleOauthRedirectState(
38+
nonce=secrets.token_urlsafe(16),
39+
callback_success_url=callback_success_url,
40+
callback_failure_url=callback_failure_url,
41+
)
42+
43+
44+
class GoogleOauthRedirectStateWebEncoder(
45+
RealmEncoder[GoogleOauthRedirectState, WebRealm]
46+
):
47+
"""Encode OAuth redirect state for the Google authorisation URL."""
48+
49+
def encode(self, value: GoogleOauthRedirectState) -> RealmThing:
50+
"""Encode to a base64url JSON string."""
51+
payload = {
52+
"v": _STATE_VERSION,
53+
"nonce": value.nonce,
54+
"callback_success_url": value.callback_success_url.the_url,
55+
"callback_failure_url": value.callback_failure_url.the_url,
56+
}
57+
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
58+
return encoded.rstrip("=")
59+
60+
61+
class GoogleOauthRedirectStateWebDecoder(
62+
RealmDecoder[GoogleOauthRedirectState, WebRealm]
63+
):
64+
"""Decode OAuth redirect state from the Google authorisation URL."""
65+
66+
def decode(self, value: RealmThing) -> GoogleOauthRedirectState:
67+
"""Decode from a base64url JSON string."""
68+
if not isinstance(value, str):
69+
raise RealmDecodingError("Expected Google OAuth state to be a string")
70+
71+
padding = "=" * (-len(value) % 4)
72+
try:
73+
raw = base64.urlsafe_b64decode(f"{value}{padding}")
74+
payload = json.loads(raw)
75+
except (ValueError, json.JSONDecodeError) as err:
76+
raise RealmDecodingError("Invalid Google OAuth state") from err
77+
78+
if not isinstance(payload, dict):
79+
raise RealmDecodingError("Invalid Google OAuth state")
80+
81+
version = payload.get("v")
82+
nonce = payload.get("nonce")
83+
callback_success_url = payload.get("callback_success_url")
84+
callback_failure_url = payload.get("callback_failure_url")
85+
86+
if (
87+
version != _STATE_VERSION
88+
or not isinstance(nonce, str)
89+
or not isinstance(callback_success_url, str)
90+
or not isinstance(callback_failure_url, str)
91+
):
92+
raise RealmDecodingError("Invalid Google OAuth state")
93+
94+
return GoogleOauthRedirectState(
95+
nonce=nonce,
96+
callback_success_url=SystemUrl(callback_success_url),
97+
callback_failure_url=SystemUrl(callback_failure_url),
98+
)

src/core/jupiter/core/auth/sub/google/oauth_client.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from authlib.integrations.base_client.errors import OAuthError
88
from authlib.integrations.httpx_client import AsyncOAuth2Client
99
from jupiter.core.auth.sub.google.google_auth_code import GoogleAuthCode
10+
from jupiter.core.auth.sub.google.google_oauth_redirect_state import (
11+
GoogleOauthRedirectState,
12+
)
1013
from jupiter.core.auth.sub.google.id_token_claims import GoogleIdTokenClaims
1114
from jupiter.core.auth.sub.google.oauth_token_response import GoogleOAuthTokenResponse
1215
from jupiter.core.auth.sub.google.refresh_token_encrypted import (
@@ -66,14 +69,31 @@ def __init__(
6669
client_secret=self._client_secret,
6770
)
6871

69-
def get_authorisation_url(self, callback_uri: SystemUrl) -> tuple[URL, str]:
72+
def get_authorisation_url(
73+
self,
74+
ready_url: SystemUrl,
75+
callback_success_url: SystemUrl,
76+
callback_failure_url: SystemUrl,
77+
) -> tuple[URL, str]:
7078
"""Get the authorisation url and OAuth state."""
71-
authorisation_url, state = self._client.create_authorization_url(
79+
state = cast(
80+
str,
81+
self._realm_codec_registry.get_encoder(
82+
GoogleOauthRedirectState, WebRealm
83+
).encode(
84+
GoogleOauthRedirectState.new(
85+
callback_success_url,
86+
callback_failure_url,
87+
)
88+
),
89+
)
90+
authorisation_url, _ = self._client.create_authorization_url(
7291
_GOOGLE_AUTHORISATION_URL,
7392
scope="openid email profile",
7493
access_type="offline",
7594
prompt="consent",
76-
redirect_uri=callback_uri.the_url,
95+
redirect_uri=ready_url.the_url,
96+
state=state,
7797
)
7898
return URL(authorisation_url), state
7999

src/core/jupiter/core/auth/sub/google/use_case/get_authorisation_url.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
class AuthGoogleGetAuthorisationUrlArgs(UseCaseArgsBase):
2222
"""Arguments for building a Google OAuth authorisation URL."""
2323

24-
callback_uri: SystemUrl
24+
ready_url: SystemUrl
25+
callback_success_url: SystemUrl
26+
callback_failure_url: SystemUrl
2527

2628

2729
@use_case_result
@@ -54,7 +56,11 @@ async def _execute(
5456
if self._ports.google_oauth_client is None:
5557
raise RuntimeError("Google OAuth client is not configured")
5658
authorisation_url, state = (
57-
self._ports.google_oauth_client.get_authorisation_url(args.callback_uri)
59+
self._ports.google_oauth_client.get_authorisation_url(
60+
args.ready_url,
61+
args.callback_success_url,
62+
args.callback_failure_url,
63+
)
5864
)
5965
return AuthGoogleGetAuthorisationUrlResult(
6066
authorisation_url=authorisation_url,

src/core/jupiter/core/config-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface GlobalPropertiesServer {
2424
telemetry: JupiterTelemetry;
2525
crmBackend: JupiterCrmBackend;
2626
hostedGlobalWebUiUrl: string;
27+
globalHostedInfraRoot: string;
2728
communityUrl: string;
2829
appsStorageUrl: string;
2930
macStoreUrl: string;
@@ -72,6 +73,7 @@ function loadGlobalPropertiesOnServer(): GlobalPropertiesServer {
7273
telemetry: (process.env.TELEMETRY ?? "local") as JupiterTelemetry,
7374
crmBackend: (process.env.CRM ?? "noop") as JupiterCrmBackend,
7475
hostedGlobalWebUiUrl: process.env.HOSTED_GLOBAL_WEBUI_URL as string,
76+
globalHostedInfraRoot: process.env.GLOBAL_HOSTED_INFRA_ROOT as string,
7577
communityUrl: process.env.COMMUNITY_URL as string,
7678
appsStorageUrl: process.env.APPS_STORAGE_URL as string,
7779
macStoreUrl: process.env.MAC_STORE_URL as string,

src/core/migrations/postgres/versions/2026_06_06_00_18_email_verification_attempt_ref_id_.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
77
"""
88

9-
from alembic import op
10-
119
revision = "c3cc27543077"
1210
down_revision = "9b53b5d8b981"
1311
branch_labels = None

0 commit comments

Comments
 (0)