diff --git a/cmd/portal/cobraviper.go b/cmd/portal/cobraviper.go index f836d014bd..b70ef598b6 100644 --- a/cmd/portal/cobraviper.go +++ b/cmd/portal/cobraviper.go @@ -64,3 +64,8 @@ var ArgPlanNameForAppUpdate = &cobraviper.StringArgument{ Usage: "Plan name", DefaultValue: "custom", } + +var ArgAppHostSuffix = &cobraviper.StringArgument{ + ArgumentName: "app-host-suffix", + Usage: "App host suffix", +} diff --git a/cmd/portal/migrate_resources_migrate_cookie_domain.go b/cmd/portal/migrate_resources_migrate_cookie_domain.go new file mode 100644 index 0000000000..b79c84aa8a --- /dev/null +++ b/cmd/portal/migrate_resources_migrate_cookie_domain.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/base64" + "fmt" + "log" + "net/url" + "strings" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + "github.com/authgear/authgear-server/cmd/portal/internal" + "github.com/authgear/authgear-server/pkg/util/httputil" +) + +var migrateCookieDomainAppHostSuffix string + +var cmdInternalMigrateCookieDomain = &cobra.Command{ + Use: "migrate-cookie-domain", + Short: "Set cookie domain for apps which are using custom domain", + RunE: func(cmd *cobra.Command, args []string) error { + binder := getBinder() + + dbURL, err := binder.GetRequiredString(cmd, ArgDatabaseURL) + if err != nil { + return err + } + + dbSchema, err := binder.GetRequiredString(cmd, ArgDatabaseSchema) + if err != nil { + return err + } + + migrateCookieDomainAppHostSuffix, err = binder.GetRequiredString(cmd, ArgAppHostSuffix) + if err != nil { + return err + } + + internal.MigrateResources(&internal.MigrateResourcesOptions{ + DatabaseURL: dbURL, + DatabaseSchema: dbSchema, + UpdateConfigSourceFunc: migrateCookieDomain, + DryRun: &MigrateResourcesDryRun, + }) + + return nil + }, +} + +func migrateCookieDomain(appID string, configSourceData map[string]string, dryRun bool) error { + encodedData := configSourceData["authgear.yaml"] + decoded, err := base64.StdEncoding.DecodeString(encodedData) + if err != nil { + return fmt.Errorf("failed decode authgear.yaml: %w", err) + } + + if dryRun { + log.Printf("Converting app (%s)", appID) + log.Printf("Before updated:") + log.Printf("\n%s\n", string(decoded)) + } + + m := make(map[string]interface{}) + err = yaml.Unmarshal(decoded, &m) + if err != nil { + return fmt.Errorf("failed unmarshal yaml: %w", err) + } + + httpConfig, ok := m["http"].(map[string]interface{}) + if !ok { + return nil + } + + publicOrigin, ok := httpConfig["public_origin"].(string) + if !ok { + return fmt.Errorf("cannot read public origin from authgear.yaml: %s", appID) + } + + if strings.HasSuffix(publicOrigin, migrateCookieDomainAppHostSuffix) { + // skip default domain + log.Printf("skip default domain...") + return nil + } + + _, ok = httpConfig["cookie_domain"].(string) + if ok { + // skip the config that has cookie_domain + log.Printf("skip config that has cookie_domain...") + return nil + } + + u, err := url.Parse(publicOrigin) + if err != nil { + return fmt.Errorf("failed to parse public origin: %w", err) + } + + cookieDomain := httputil.CookieDomainWithoutPort(u.Host) + httpConfig["cookie_domain"] = cookieDomain + + migrated, err := yaml.Marshal(m) + if err != nil { + return fmt.Errorf("failed marshal yaml: %w", err) + } + + if dryRun { + log.Printf("After updated:") + log.Printf("\n%s\n", string(migrated)) + } + + configSourceData["authgear.yaml"] = base64.StdEncoding.EncodeToString(migrated) + return nil +} + +func init() { + binder := getBinder() + cmdInternalBreakingChangeMigrateResources.AddCommand(cmdInternalMigrateCookieDomain) + binder.BindString(cmdInternalMigrateCookieDomain.Flags(), ArgAppHostSuffix) +} diff --git a/pkg/portal/graphql/domain.go b/pkg/portal/graphql/domain.go index 18c1eb73ea..a9ecab68e9 100644 --- a/pkg/portal/graphql/domain.go +++ b/pkg/portal/graphql/domain.go @@ -11,6 +11,7 @@ var domain = graphql.NewObject(graphql.ObjectConfig{ "id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)}, "createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)}, "domain": &graphql.Field{Type: graphql.NewNonNull(graphql.String)}, + "cookieDomain": &graphql.Field{Type: graphql.NewNonNull(graphql.String)}, "apexDomain": &graphql.Field{Type: graphql.NewNonNull(graphql.String)}, "verificationDNSRecord": &graphql.Field{Type: graphql.NewNonNull(graphql.String)}, "isCustom": &graphql.Field{Type: graphql.NewNonNull(graphql.Boolean)}, diff --git a/pkg/portal/model/domain.go b/pkg/portal/model/domain.go index d1f1e35382..c5280cc154 100644 --- a/pkg/portal/model/domain.go +++ b/pkg/portal/model/domain.go @@ -13,6 +13,7 @@ type Domain struct { AppID string `json:"appID"` CreatedAt time.Time `json:"createdAt"` Domain string `json:"domain"` + CookieDomain string `json:"cookieDomain"` ApexDomain string `json:"apexDomain"` VerificationDNSRecord string `json:"verificationDNSRecord"` IsCustom bool `json:"isCustom"` diff --git a/pkg/portal/service/domain.go b/pkg/portal/service/domain.go index e7881c4d98..8b487b6ff1 100644 --- a/pkg/portal/service/domain.go +++ b/pkg/portal/service/domain.go @@ -20,6 +20,7 @@ import ( "github.com/authgear/authgear-server/pkg/lib/infra/db/globaldb" "github.com/authgear/authgear-server/pkg/portal/model" "github.com/authgear/authgear-server/pkg/util/clock" + "github.com/authgear/authgear-server/pkg/util/httputil" corerand "github.com/authgear/authgear-server/pkg/util/rand" "github.com/authgear/authgear-server/pkg/util/uuid" ) @@ -408,12 +409,21 @@ func (d *domain) toModel(isVerified bool) *model.Domain { prefix = "pending:" } + // for default domain, original domain will be used for cookie domain + // for custom domain, cookie domain is derived from the + // CookieDomainWithoutPort function + cookieDomain := d.Domain + if d.IsCustom { + cookieDomain = httputil.CookieDomainWithoutPort(d.Domain) + } + return &model.Domain{ // Base64-encoded to avoid invalid k8s resource label invalid chars ID: base64.RawURLEncoding.EncodeToString([]byte(prefix + d.ID)), AppID: d.AppID, CreatedAt: d.CreatedAt, Domain: d.Domain, + CookieDomain: cookieDomain, ApexDomain: d.ApexDomain, VerificationDNSRecord: domainVerificationDNSRecord(d.VerificationNonce), IsCustom: d.IsCustom, diff --git a/pkg/util/httputil/cookie.go b/pkg/util/httputil/cookie.go index a65af8bfe7..b18b5ec5a1 100644 --- a/pkg/util/httputil/cookie.go +++ b/pkg/util/httputil/cookie.go @@ -41,11 +41,12 @@ func UpdateCookie(w http.ResponseWriter, cookie *http.Cookie) { header["Set-Cookie"] = setCookies } -// CookieDomainFromETLDPlusOneWithoutPort derives host from r. +// CookieDomainWithoutPort derives host from r. // If host has port, the port is removed. +// If host-1 is longer than ETLD+1, host-1 is returned. // If ETLD+1 cannot be derived, an empty string is returned. // The return value never have port. -func CookieDomainFromETLDPlusOneWithoutPort(host string) string { +func CookieDomainWithoutPort(host string) string { // Trim the port if it is present. // We have to trim the port first. // Passing host:port to EffectiveTLDPlusOne confuses it. @@ -64,12 +65,18 @@ func CookieDomainFromETLDPlusOneWithoutPort(host string) string { return "" } - host, err := publicsuffix.EffectiveTLDPlusOne(host) + eTLDPlusOne, err := publicsuffix.EffectiveTLDPlusOne(host) if err != nil { return "" } - return host + // host has a valid ETLDPlusOne, it's safe to split the domain with . + hostMinusOne := host[1+strings.Index(host, "."):] + if len(hostMinusOne) > len(eTLDPlusOne) { + return hostMinusOne + } + + return eTLDPlusOne } type CookieManager struct { @@ -85,7 +92,7 @@ func (f *CookieManager) fixupCookie(cookie *http.Cookie) { cookie.Secure = proto == "https" if cookie.Domain == "" { - cookie.Domain = CookieDomainFromETLDPlusOneWithoutPort(host) + cookie.Domain = CookieDomainWithoutPort(host) } if cookie.SameSite == http.SameSiteNoneMode && diff --git a/pkg/util/httputil/cookie_test.go b/pkg/util/httputil/cookie_test.go index bed34fecdf..76b08836f0 100644 --- a/pkg/util/httputil/cookie_test.go +++ b/pkg/util/httputil/cookie_test.go @@ -106,10 +106,10 @@ func TestUpdateCookie(t *testing.T) { }) } -func TestCookieDomainFromETLDPlusOneWithoutPort(t *testing.T) { - Convey("CookieDomainFromETLDPlusOneWithoutPort", t, func() { +func TestCookieDomainWithoutPort(t *testing.T) { + Convey("CookieDomainWithoutPort", t, func() { check := func(in string, out string) { - actual := httputil.CookieDomainFromETLDPlusOneWithoutPort(in) + actual := httputil.CookieDomainWithoutPort(in) So(out, ShouldEqual, actual) } check("localhost", "") @@ -139,5 +139,9 @@ func TestCookieDomainFromETLDPlusOneWithoutPort(t *testing.T) { check("www.example.co.jp", "example.co.jp") check("www.example.co.jp:80", "example.co.jp") check("www.example.co.jp:8080", "example.co.jp") + + check("auth.app.example.co.jp", "app.example.co.jp") + check("auth.app.example.co.jp:80", "app.example.co.jp") + check("auth.app.example.co.jp:8080", "app.example.co.jp") }) } diff --git a/portal/src/graphql/portal/ApplicationsConfigurationScreen.tsx b/portal/src/graphql/portal/ApplicationsConfigurationScreen.tsx index c43071fce9..79eea1e604 100644 --- a/portal/src/graphql/portal/ApplicationsConfigurationScreen.tsx +++ b/portal/src/graphql/portal/ApplicationsConfigurationScreen.tsx @@ -47,6 +47,7 @@ const COPY_ICON_STLYES: IButtonStyles = { interface FormState { publicOrigin: string; + cookieDomain?: string; clients: OAuthClientConfig[]; allowedOrigins: string[]; persistentCookie: boolean; @@ -58,6 +59,7 @@ interface FormState { function constructFormState(config: PortalAPIAppConfig): FormState { return { publicOrigin: config.http?.public_origin ?? "", + cookieDomain: config.http?.cookie_domain, clients: config.oauth?.clients ?? [], allowedOrigins: config.http?.allowed_origins ?? [], persistentCookie: !(config.session?.cookie_non_persistent ?? false), @@ -284,7 +286,8 @@ const SessionConfigurationWidget: React.FC = diff --git a/portal/src/graphql/portal/CustomDomainListScreen.module.scss b/portal/src/graphql/portal/CustomDomainListScreen.module.scss index 68b56efef4..ce81c06d05 100644 --- a/portal/src/graphql/portal/CustomDomainListScreen.module.scss +++ b/portal/src/graphql/portal/CustomDomainListScreen.module.scss @@ -1,5 +1,6 @@ -.content { - margin: 0 32px; +.root { + display: flex; + flex-direction: column; } .verifySuccessMessageBar { @@ -10,12 +11,14 @@ } } +.widget { + margin: 10px 15px; + max-width: 750px; +} + .description { display: block; margin: 16px 8px; - width: 650px; - font-size: 13px; - line-height: 1.3; } .domainListColumn { diff --git a/portal/src/graphql/portal/CustomDomainListScreen.tsx b/portal/src/graphql/portal/CustomDomainListScreen.tsx index 583734eb15..2b606a7f39 100644 --- a/portal/src/graphql/portal/CustomDomainListScreen.tsx +++ b/portal/src/graphql/portal/CustomDomainListScreen.tsx @@ -51,6 +51,8 @@ import { useAppConfigForm, } from "../../hook/useAppConfigForm"; import { useAppFeatureConfigQuery } from "./query/appFeatureConfigQuery"; +import ScreenContent from "../../ScreenContent"; +import Widget from "../../Widget"; function getOriginFromDomain(domain: string): string { // assume domain has no scheme @@ -69,6 +71,7 @@ function getHostFromOrigin(urlOrigin: string): string { interface DomainListItem { id?: string; domain: string; + cookieDomain: string; urlOrigin: string; isVerified: boolean; isCustom: boolean; @@ -214,16 +217,13 @@ const AddDomainSection: React.FC = function AddDomainSection() { interface DomainListActionButtonsProps { domainID?: string; domain: string; + cookieDomain: string; urlOrigin: string; isCustomDomain: boolean; isVerified: boolean; isPublicOrigin: boolean; onDeleteClick: (domainID: string, domain: string) => void; - onDomainActivate: ( - urlOrigin: string, - domain: string, - isCustom: boolean - ) => void; + onDomainActivate: (urlOrigin: string, cookieDomain: string) => void; } const DomainListActionButtons: React.FC = @@ -232,6 +232,7 @@ const DomainListActionButtons: React.FC = const { domainID, domain, + cookieDomain, urlOrigin, isCustomDomain, isVerified, @@ -248,8 +249,8 @@ const DomainListActionButtons: React.FC = const showActivate = domainID && isVerified && !isPublicOrigin; const onActivateClick = useCallback(() => { - onDomainActivateProps(urlOrigin, domain, isCustomDomain); - }, [urlOrigin, domain, isCustomDomain, onDomainActivateProps]); + onDomainActivateProps(urlOrigin, cookieDomain); + }, [urlOrigin, cookieDomain, onDomainActivateProps]); const onVerifyClicked = useCallback(() => { navigate(`./${domainID}/verify`); @@ -539,6 +540,7 @@ const CustomDomainListContent: React.FC = return { id: domain.id, domain: domain.domain, + cookieDomain: domain.cookieDomain, urlOrigin: urlOrigin, isVerified: domain.isVerified, isCustom: domain.isCustom, @@ -548,9 +550,12 @@ const CustomDomainListContent: React.FC = const found = list.find((domain) => domain.isPublicOrigin); if (!found) { + // cannot found a domain that match the public origin + // should only happen in local development list.unshift({ domain: getHostFromOrigin(prevSavedPublicOrigin) || prevSavedPublicOrigin, + cookieDomain: "", urlOrigin: prevSavedPublicOrigin, isCustom: false, isVerified: false, @@ -569,20 +574,12 @@ const CustomDomainListContent: React.FC = }, []); const onDomainActivate = useCallback( - (urlOrigin: string, domain: string, isCustom: boolean) => { - // if the domain is the default app domain - // set cookie_domain to the domain - // to ensure the cookies are isolated between apps - - // if the domain is a custom domain - // clear the cookie_domain config - // if the cookie_domain config is not provided, auth server will - // set cookie to the eTLD+1 domain - const cookieDomain = isCustom ? undefined : domain; + (urlOrigin: string, cookieDomain: string) => { + // set cookieDomain to the domain's cookieDomain setState((state) => ({ ...state, publicOrigin: urlOrigin, - cookieDomain: cookieDomain, + cookieDomain: cookieDomain === "" ? undefined : cookieDomain, })); }, [setState] @@ -635,6 +632,7 @@ const CustomDomainListContent: React.FC = = ); return ( -
+ - - - - {customDomainDisabled && ( - - - - )} - + + + + + {customDomainDisabled && ( + + + + )} + + = onConfirmClick={confirmUpdatePublicOrigin} dismissDialog={dismissUpdatePublicOriginDialog} /> -
+ ); }; diff --git a/portal/src/graphql/portal/VerifyDomainScreen.module.scss b/portal/src/graphql/portal/VerifyDomainScreen.module.scss index 0fc782e03a..11f318ecd9 100644 --- a/portal/src/graphql/portal/VerifyDomainScreen.module.scss +++ b/portal/src/graphql/portal/VerifyDomainScreen.module.scss @@ -1,21 +1,19 @@ .root { - padding-top: 40px; + display: flex; + flex-direction: column; + // to align with the custom domain list page + // the height of hidden message bar + margin-top: 32px; } -.header { - margin: 0 32px; +.widget { + margin: 10px 15px; + max-width: 750px; } .description { display: block; - margin: 0 32px 15px; - width: 650px; - font-size: 13px; - line-height: 1.3; - - .domainName { - font-weight: 800; - } + margin: 16px 8px; } .dnsRecordListColumn { @@ -41,5 +39,5 @@ } .controlButtons { - margin: 25px 32px 0; + margin: 25px 12px; } diff --git a/portal/src/graphql/portal/VerifyDomainScreen.tsx b/portal/src/graphql/portal/VerifyDomainScreen.tsx index b287e9cefe..40d4d14835 100644 --- a/portal/src/graphql/portal/VerifyDomainScreen.tsx +++ b/portal/src/graphql/portal/VerifyDomainScreen.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useContext, useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Context, FormattedMessage } from "@oursky/react-messageformat"; import { - DefaultButton, DetailsList, IColumn, IconButton, @@ -23,6 +22,8 @@ import { useCopyFeedback } from "../../hook/useCopyFeedback"; import styles from "./VerifyDomainScreen.module.scss"; import { ErrorParseRule } from "../../error/parse"; +import ScreenContent from "../../ScreenContent"; +import Widget from "../../Widget"; interface VerifyDomainProps { domain: Domain; @@ -167,10 +168,6 @@ const VerifyDomain: React.FC = function VerifyDomain( .catch(() => {}); }, [verifyDomain, domain, navigate]); - const onCancelClick = useCallback(() => { - navigate("../.."); - }, [navigate]); - const errorRules: ErrorParseRule[] = useMemo(() => { return [ { @@ -197,37 +194,38 @@ const VerifyDomain: React.FC = function VerifyDomain( }, []); return ( -
- - - - - - {domain.domain} - - - - + + + + + + - - - - - - -
+ + + + + + + ); }; @@ -286,7 +284,7 @@ const VerifyDomainScreen: React.FC = function VerifyDomainScreen() { } return ( -
+