diff --git a/apps/console/src/layouts/MainLayout.tsx b/apps/console/src/layouts/MainLayout.tsx index 5f53e401c..ab69ef10c 100644 --- a/apps/console/src/layouts/MainLayout.tsx +++ b/apps/console/src/layouts/MainLayout.tsx @@ -154,7 +154,7 @@ export function MainLayout() { icon={IconBook} to={`${prefix}/obligations`} /> - (MainLayoutQuery, { organizationId }).viewer - .user; + const user = useLazyLoadQuery(MainLayoutQuery, { + organizationId, + }).viewer.user; const handleLogout: React.MouseEventHandler = async ( e ) => { e.preventDefault(); - fetch(buildEndpoint("/auth/logout"), { + fetch(buildEndpoint("/connect/logout"), { method: "DELETE", headers: { "Content-Type": "application/json", @@ -274,13 +275,19 @@ interface InvitationsResponse { invitations: Invitation[]; } -function OrganizationSelectorWrapper({ organizationId }: { organizationId: string }) { - const data = useLazyLoadQuery(MainLayoutQuery, { organizationId }); +function OrganizationSelectorWrapper({ + organizationId, +}: { + organizationId: string; +}) { + const data = useLazyLoadQuery(MainLayoutQuery, { + organizationId, + }); return ; } function OrganizationSelector({ - currentOrganization + currentOrganization, }: { currentOrganization: MainLayoutQueryType["response"]["organization"]; }) { @@ -297,32 +304,32 @@ function OrganizationSelector({ // Fetch organizations and invitations in parallel const [orgsResponse, invitationsResponse] = await Promise.all([ - fetch('/auth/organizations', { credentials: 'include' }), - fetch('/auth/invitations', { credentials: 'include' }) + fetch("/connect/organizations", { credentials: "include" }), + fetch("/connect/invitations", { credentials: "include" }), ]); if (!orgsResponse.ok) { - throw new Error('Failed to fetch organizations'); + throw new Error("Failed to fetch organizations"); } if (!invitationsResponse.ok) { - throw new Error('Failed to fetch invitations'); + throw new Error("Failed to fetch invitations"); } const orgsData: OrganizationsResponse = await orgsResponse.json(); - const invitationsData: InvitationsResponse = await invitationsResponse.json(); + const invitationsData: InvitationsResponse = + await invitationsResponse.json(); - // Count pending invitations (those without acceptedAt) const pendingCount = invitationsData.invitations.filter( - inv => !inv.acceptedAt + (inv) => !inv.acceptedAt ).length; setOrganizations(orgsData.organizations); setPendingInvitationsCount(pendingCount); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - console.error('Failed to fetch data:', err); + setError(err instanceof Error ? err.message : "Unknown error"); + console.error("Failed to fetch data:", err); } finally { setIsLoading(false); } @@ -334,11 +341,7 @@ function OrganizationSelector({ if (error) { return (
-
@@ -355,7 +358,7 @@ function OrganizationSelector({ iconAfter={IconChevronGrabberVertical} disabled={isLoading} > - {isLoading ? __("Loading...") : (currentOrganization?.name || "")} + {isLoading ? __("Loading...") : currentOrganization?.name || ""} } > @@ -370,7 +373,8 @@ function OrganizationSelector({ ) : ( organizations.map((organization) => { - const isAuthenticated = organization.authStatus === "authenticated"; + const isAuthenticated = + organization.authStatus === "authenticated"; const isExpired = organization.authStatus === "expired"; const needsAuth = organization.authStatus === "unauthenticated"; @@ -378,16 +382,16 @@ function OrganizationSelector({ ? `/organizations/${organization.id}` : organization.loginUrl; - const isSAMLUrl = targetUrl.includes('/auth/saml/'); + const isSAMLUrl = targetUrl.includes("/connect/saml/"); return ( - + {isSAMLUrl ? ( - + {organization.name} {isAuthenticated && ( @@ -401,7 +405,10 @@ function OrganizationSelector({ ) : ( - + {organization.name} {isAuthenticated && ( diff --git a/apps/console/src/pages/OrganizationsPage.tsx b/apps/console/src/pages/OrganizationsPage.tsx index 42183ae7b..ad9c4fe5b 100644 --- a/apps/console/src/pages/OrganizationsPage.tsx +++ b/apps/console/src/pages/OrganizationsPage.tsx @@ -51,7 +51,7 @@ export default function OrganizationsPage() { useEffect(() => { const fetchOrganizations = async () => { try { - const response = await fetch('/auth/organizations', { + const response = await fetch('/connect/organizations', { credentials: 'include', }); @@ -75,7 +75,7 @@ export default function OrganizationsPage() { useEffect(() => { const fetchInvitations = async () => { try { - const response = await fetch('/auth/invitations', { + const response = await fetch('/connect/invitations', { credentials: 'include', }); @@ -98,7 +98,7 @@ export default function OrganizationsPage() { const handleAcceptInvitation = async (invitationId: string, organizationId: string) => { setIsAccepting(true); try { - const response = await fetch('/auth/invitations/accept', { + const response = await fetch('/connect/invitations/accept', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -281,7 +281,7 @@ function OrganizationCard({ organization }: OrganizationCardProps) { }; // Check if the URL is a backend SAML endpoint - const isSAMLUrl = targetUrl.includes('/auth/saml/'); + const isSAMLUrl = targetUrl.includes('/connect/saml/'); return ( diff --git a/apps/console/src/pages/auth/ConfirmEmailPage.tsx b/apps/console/src/pages/auth/ConfirmEmailPage.tsx index abaa29787..f788e79a5 100644 --- a/apps/console/src/pages/auth/ConfirmEmailPage.tsx +++ b/apps/console/src/pages/auth/ConfirmEmailPage.tsx @@ -111,7 +111,7 @@ export default function ConfirmEmailPage() {

{__("Your email has been confirmed successfully!")}

- @@ -141,7 +141,7 @@ export default function ConfirmEmailPage() { {!isConfirmed && (

{__("Back to Login")} diff --git a/apps/console/src/pages/auth/ForgotPasswordPage.tsx b/apps/console/src/pages/auth/ForgotPasswordPage.tsx index 085e6165e..8eabd2de6 100644 --- a/apps/console/src/pages/auth/ForgotPasswordPage.tsx +++ b/apps/console/src/pages/auth/ForgotPasswordPage.tsx @@ -23,7 +23,7 @@ export default function ForgotPasswordPage() { const onSubmit = handleSubmit(async (data) => { const response = await fetch( - buildEndpoint("/auth/forget-password"), + buildEndpoint("/connect/forget-password"), { method: "POST", headers: { @@ -81,7 +81,7 @@ export default function ForgotPasswordPage() {

{__("Remember your password?")}{" "} {__("Back to login")} @@ -124,7 +124,7 @@ export default function ForgotPasswordPage() {

{__("Remember your password?")}{" "} {__("Back to login")} diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx index 8c96bd5dd..23a198ac0 100644 --- a/apps/console/src/pages/auth/LoginPage.tsx +++ b/apps/console/src/pages/auth/LoginPage.tsx @@ -33,7 +33,7 @@ export default function LoginPage() { setIsLoading(true); try { - const res = await fetch(buildEndpoint("/auth/login"), { + const res = await fetch(buildEndpoint("/connect/login"), { method: "POST", headers: { "Content-Type": "application/json", @@ -68,7 +68,7 @@ export default function LoginPage() { setIsChecking(true); try { - const res = await fetch(buildEndpoint("/auth/check-sso"), { + const res = await fetch(buildEndpoint("/connect/check-sso"), { method: "POST", headers: { "Content-Type": "application/json", @@ -85,7 +85,7 @@ export default function LoginPage() { if (data.ssoAvailable && data.samlConfigId) { window.location.href = buildEndpoint( - `/auth/saml/login/${data.samlConfigId}` + `/connect/saml/login/${data.samlConfigId}` ); } else { throw new Error(__("SSO not available for this email domain")); @@ -146,7 +146,7 @@ export default function LoginPage() {

{__("Don't have an account ?")}{" "} - + {__("Register")}
@@ -154,7 +154,7 @@ export default function LoginPage() {
{__("Forgot password?")}{" "} {__("Reset password")} @@ -206,7 +206,7 @@ export default function LoginPage() {
{__("Don't have an account ?")}{" "} - + {__("Register")}
@@ -214,7 +214,7 @@ export default function LoginPage() {
{__("Forgot password?")}{" "} {__("Reset password")} @@ -257,7 +257,7 @@ export default function LoginPage() {
{__("Don't have an account ?")}{" "} - + {__("Register")}
diff --git a/apps/console/src/pages/auth/RegisterPage.tsx b/apps/console/src/pages/auth/RegisterPage.tsx index 5b9d8d521..838b4b8e5 100644 --- a/apps/console/src/pages/auth/RegisterPage.tsx +++ b/apps/console/src/pages/auth/RegisterPage.tsx @@ -26,7 +26,7 @@ export default function RegisterPage() { const onSubmit = handleSubmit(async (data) => { const response = await fetch( - buildEndpoint("/auth/register"), + buildEndpoint("/connect/register"), { method: "POST", headers: { @@ -106,7 +106,7 @@ export default function RegisterPage() {

{__("Already have an account?")}{" "} {__("Log in here")} diff --git a/apps/console/src/pages/auth/ResetPasswordPage.tsx b/apps/console/src/pages/auth/ResetPasswordPage.tsx index 966b2d05a..a4f70158d 100644 --- a/apps/console/src/pages/auth/ResetPasswordPage.tsx +++ b/apps/console/src/pages/auth/ResetPasswordPage.tsx @@ -44,7 +44,7 @@ export default function ResetPasswordPage() { } const response = await fetch( - buildEndpoint("/auth/reset-password"), + buildEndpoint("/connect/reset-password"), { method: "POST", headers: { @@ -74,7 +74,7 @@ export default function ResetPasswordPage() { description: __("Password reset successfully"), variant: "success", }); - navigate("/authentication/login", { replace: true }); + navigate("/auth/login", { replace: true }); }); usePageTitle(__("Reset password")); @@ -118,7 +118,7 @@ export default function ResetPasswordPage() {

{__("Remember your password?")}{" "} {__("Log in here")} diff --git a/apps/console/src/pages/auth/SignupFromInvitationPage.tsx b/apps/console/src/pages/auth/SignupFromInvitationPage.tsx index 9a2804f06..8b6c8aeb7 100644 --- a/apps/console/src/pages/auth/SignupFromInvitationPage.tsx +++ b/apps/console/src/pages/auth/SignupFromInvitationPage.tsx @@ -51,7 +51,7 @@ export default function SignupFromInvitationPage() { } const response = await fetch( - buildEndpoint("/auth/signup-from-invitation"), + buildEndpoint("/connect/signup-from-invitation"), { method: "POST", headers: { @@ -125,7 +125,7 @@ export default function SignupFromInvitationPage() {

{__("Already have an account?")}{" "} {__("Log in here")} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index a7b779e1b..c1823efe0 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -50,7 +50,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) { const error = useRouteError() ?? propsError; if (error instanceof UnAuthenticatedError) { - return ; + return ; } return ; @@ -58,7 +58,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) { const routes = [ { - path: "/authentication", + path: "/auth", Component: AuthLayout, children: [ { diff --git a/cfg/dev.yaml b/cfg/dev.yaml index fe5936f6a..acd5fe00a 100644 --- a/cfg/dev.yaml +++ b/cfg/dev.yaml @@ -9,7 +9,7 @@ unit: max-queue-size: 2048 probod: - hostname: "https://gearnode.probo.engineering" + base-url: "https://gearnode.probo.engineering" encryption-key: "thisisnotasecretAAAAAAAAAAAAAAAAAAAAAAAAAAA=" chrome-dp-addr: "localhost:9222" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 23a140640..102bf7549 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -23,7 +23,7 @@ unit: max-queue-size: 10000 probod: - hostname: "localhost:8080" + base-url: "http://localhost:8080" encryption-key: "base64-encoded-encryption-key" chrome-dp-addr: "localhost:9222" @@ -249,11 +249,11 @@ Probod provides automatic structured JSON logging with: ### General Settings -#### `hostname` (string) +#### `base-url` (string) -**Default**: `"localhost:8080"` +**Default**: `"http://localhost:8080"` -The hostname and port where the Probod service will be accessible externally. This setting affects URL generation for redirects and API responses. +The base URL where the Probod service will be accessible externally. This should include the scheme (http or https), hostname, and optionally port. This setting affects URL generation for emails, redirects, and API responses. For production deployments, use the full HTTPS URL (e.g., `"https://app.example.com"`). #### `encryption-key` (string) diff --git a/packages/emails/emails.go b/packages/emails/emails.go index c94e18478..7f3497dce 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -28,7 +28,7 @@ import ( var Templates embed.FS const ( - logoURLFormat = "https://%s/logos/probo.png" + logoURLPath = "/logos/probo.png" subjectConfirmEmail = "Confirm your email address" subjectPasswordReset = "Reset your password" @@ -56,7 +56,7 @@ var ( trustCenterAccessTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-access.txt.tmpl")) ) -func RenderConfirmEmail(hostname, fullName, confirmationUrl string) (subject string, textBody string, htmlBody *string, err error) { +func RenderConfirmEmail(baseURL, fullName, confirmationUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { FullName string ConfirmationUrl string @@ -64,14 +64,14 @@ func RenderConfirmEmail(hostname, fullName, confirmationUrl string) (subject str }{ FullName: fullName, ConfirmationUrl: confirmationUrl, - LogoURL: fmt.Sprintf(logoURLFormat, hostname), + LogoURL: baseURL + logoURLPath, } textBody, htmlBody, err = renderEmail(confirmEmailTextTemplate, confirmEmailHTMLTemplate, data) return subjectConfirmEmail, textBody, htmlBody, err } -func RenderPasswordReset(hostname, fullName, resetUrl string) (subject string, textBody string, htmlBody *string, err error) { +func RenderPasswordReset(baseURL, fullName, resetUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { FullName string ResetUrl string @@ -79,14 +79,14 @@ func RenderPasswordReset(hostname, fullName, resetUrl string) (subject string, t }{ FullName: fullName, ResetUrl: resetUrl, - LogoURL: fmt.Sprintf(logoURLFormat, hostname), + LogoURL: baseURL + logoURLPath, } textBody, htmlBody, err = renderEmail(passwordResetTextTemplate, passwordResetHTMLTemplate, data) return subjectPasswordReset, textBody, htmlBody, err } -func RenderInvitation(hostname, fullName, organizationName, invitationUrl string) (subject string, textBody string, htmlBody *string, err error) { +func RenderInvitation(baseURL, fullName, organizationName, invitationUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { FullName string OrganizationName string @@ -96,14 +96,14 @@ func RenderInvitation(hostname, fullName, organizationName, invitationUrl string FullName: fullName, OrganizationName: organizationName, InvitationUrl: invitationUrl, - LogoURL: fmt.Sprintf(logoURLFormat, hostname), + LogoURL: baseURL + logoURLPath, } textBody, htmlBody, err = renderEmail(invitationTextTemplate, invitationHTMLTemplate, data) return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err } -func RenderDocumentSigning(hostname, fullName, organizationName, signingUrl string) (subject string, textBody string, htmlBody *string, err error) { +func RenderDocumentSigning(baseURL, fullName, organizationName, signingUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { FullName string OrganizationName string @@ -113,14 +113,14 @@ func RenderDocumentSigning(hostname, fullName, organizationName, signingUrl stri FullName: fullName, OrganizationName: organizationName, SigningUrl: signingUrl, - LogoURL: fmt.Sprintf(logoURLFormat, hostname), + LogoURL: baseURL + logoURLPath, } textBody, htmlBody, err = renderEmail(documentSigningTextTemplate, documentSigningHTMLTemplate, data) return fmt.Sprintf(subjectDocumentSigning, organizationName), textBody, htmlBody, err } -func RenderDocumentExport(hostname, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { +func RenderDocumentExport(baseURL, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { FullName string DownloadUrl string @@ -128,14 +128,14 @@ func RenderDocumentExport(hostname, fullName, downloadUrl string) (subject strin }{ FullName: fullName, DownloadUrl: downloadUrl, - LogoURL: fmt.Sprintf(logoURLFormat, hostname), + LogoURL: baseURL + logoURLPath, } textBody, htmlBody, err = renderEmail(documentExportTextTemplate, documentExportHTMLTemplate, data) return subjectDocumentExport, textBody, htmlBody, err } -func RenderFrameworkExport(hostname, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { +func RenderFrameworkExport(baseURL, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { FullName string DownloadUrl string @@ -143,14 +143,14 @@ func RenderFrameworkExport(hostname, fullName, downloadUrl string) (subject stri }{ FullName: fullName, DownloadUrl: downloadUrl, - LogoURL: fmt.Sprintf(logoURLFormat, hostname), + LogoURL: baseURL + logoURLPath, } textBody, htmlBody, err = renderEmail(frameworkExportTextTemplate, frameworkExportHTMLTemplate, data) return subjectFrameworkExport, textBody, htmlBody, err } -func RenderTrustCenterAccess(hostname, fullName, organizationName, accessUrl string, tokenDuration time.Duration) (subject string, textBody string, htmlBody *string, err error) { +func RenderTrustCenterAccess(baseURL, fullName, organizationName, accessUrl string, tokenDuration time.Duration) (subject string, textBody string, htmlBody *string, err error) { durationInDays := int(math.Round(tokenDuration.Hours() / 24)) data := struct { @@ -163,7 +163,7 @@ func RenderTrustCenterAccess(hostname, fullName, organizationName, accessUrl str FullName: fullName, OrganizationName: organizationName, AccessUrl: accessUrl, - LogoURL: fmt.Sprintf(logoURLFormat, hostname), + LogoURL: baseURL + logoURLPath, DurationInDays: durationInDays, } diff --git a/pkg/auth/access.go b/pkg/auth/access.go index 9cbd883fd..f10488ab0 100644 --- a/pkg/auth/access.go +++ b/pkg/auth/access.go @@ -74,13 +74,13 @@ func (r AccessResult) ToError(baseURL string) error { case AuthMethodPassword: return ErrPasswordAuthRequired{ OrganizationID: r.OrganizationID, - RedirectURL: fmt.Sprintf("%s/authentication/login?method=password", baseURL), + RedirectURL: fmt.Sprintf("%s/auth/login?method=password", baseURL), } case AuthMethodSAML, AuthMethodAny: return ErrSAMLAuthRequired{ ConfigID: r.SAMLConfig.ID, OrganizationID: r.OrganizationID, - RedirectURL: fmt.Sprintf("%s/auth/saml/login/%s", baseURL, r.SAMLConfig.ID), + RedirectURL: fmt.Sprintf("%s/connect/saml/login/%s", baseURL, r.SAMLConfig.ID), } default: return fmt.Errorf("access denied to organization %s", r.OrganizationID) diff --git a/pkg/auth/saml_service.go b/pkg/auth/saml_service.go index 48a9db28a..e666d8909 100644 --- a/pkg/auth/saml_service.go +++ b/pkg/auth/saml_service.go @@ -224,11 +224,11 @@ func NewSAMLService( } func (s *SAMLService) GetEntityID() string { - return fmt.Sprintf("%s/auth/saml/metadata", s.baseURL) + return fmt.Sprintf("%s/connect/saml/metadata", s.baseURL) } func (s *SAMLService) GetAcsURL() string { - return fmt.Sprintf("%s/auth/saml/consume", s.baseURL) + return fmt.Sprintf("%s/connect/saml/consume", s.baseURL) } func parseRawSAMLResponse(encodedResponse string) (*saml.Assertion, error) { @@ -564,7 +564,7 @@ func (s *SAMLService) HandleSAMLAssertion( } func (s *SAMLService) GetMetadataURL(organizationID gid.GID) string { - return fmt.Sprintf("%s/auth/saml/metadata/%s", s.baseURL, organizationID) + return fmt.Sprintf("%s/connect/saml/metadata/%s", s.baseURL, organizationID) } func (s *SAMLService) GenerateMetadata() ([]byte, error) { diff --git a/pkg/auth/service.go b/pkg/auth/service.go index 8e184d436..bd0fbb517 100644 --- a/pkg/auth/service.go +++ b/pkg/auth/service.go @@ -22,10 +22,10 @@ import ( "fmt" "net" "net/mail" - "net/url" "time" "github.com/getprobo/probo/packages/emails" + "github.com/getprobo/probo/pkg/baseurl" "github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/crypto/passwdhash" @@ -40,7 +40,6 @@ type ( pg *pg.Client encryptionKey cipher.EncryptionKey hp *passwdhash.Profile - hostname string baseURL string tokenSecret string disableSignup bool @@ -51,7 +50,6 @@ type ( pg *pg.Client encryptionKey cipher.EncryptionKey hp *passwdhash.Profile - hostname string baseURL string tokenSecret string scope coredata.Scoper @@ -187,7 +185,6 @@ func NewService( encryptionKey cipher.EncryptionKey, hp *passwdhash.Profile, tokenSecret string, - hostname string, baseURL string, disableSignup bool, invitationTokenValidity time.Duration, @@ -196,7 +193,6 @@ func NewService( pg: pgClient, encryptionKey: encryptionKey, hp: hp, - hostname: hostname, baseURL: baseURL, tokenSecret: tokenSecret, disableSignup: disableSignup, @@ -209,7 +205,6 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthService { pg: s.pg, encryptionKey: s.encryptionKey, hp: s.hp, - hostname: s.hostname, baseURL: s.baseURL, tokenSecret: s.tokenSecret, scope: coredata.NewScope(tenantID), @@ -232,13 +227,17 @@ func (s Service) ForgetPassword( return fmt.Errorf("cannot generate password reset token: %w", err) } - resetPasswordUrl := url.URL{ - Scheme: "https", - Host: s.hostname, - Path: "/auth/reset-password", - RawQuery: url.Values{ - "token": []string{passwordResetToken}, - }.Encode(), + base, err := baseurl.Parse(s.baseURL) + if err != nil { + return fmt.Errorf("cannot parse base URL: %w", err) + } + + resetPasswordUrl, err := base. + WithPath("/auth/reset-password"). + WithQuery("token", passwordResetToken). + String() + if err != nil { + return fmt.Errorf("cannot build reset password URL: %w", err) } return s.pg.WithConn( @@ -255,9 +254,9 @@ func (s Service) ForgetPassword( } subject, textBody, htmlBody, err := emails.RenderPasswordReset( - s.hostname, + s.baseURL, user.FullName, - resetPasswordUrl.String(), + resetPasswordUrl, ) if err != nil { return fmt.Errorf("cannot render password reset email: %w", err) @@ -352,19 +351,23 @@ func (s Service) SignUp( return fmt.Errorf("cannot generate confirmation token: %w", err) } - confirmationUrl := url.URL{ - Scheme: "https", - Host: s.hostname, - Path: "/auth/confirm-email", - RawQuery: url.Values{ - "token": []string{confirmationToken}, - }.Encode(), + base, err := baseurl.Parse(s.baseURL) + if err != nil { + return fmt.Errorf("cannot parse base URL: %w", err) + } + + confirmationUrl, err := base. + WithPath("/auth/confirm-email"). + WithQuery("token", confirmationToken). + String() + if err != nil { + return fmt.Errorf("cannot build confirmation URL: %w", err) } subject, textBody, htmlBody, err := emails.RenderConfirmEmail( - s.hostname, + s.baseURL, user.FullName, - confirmationUrl.String(), + confirmationUrl, ) if err != nil { return fmt.Errorf("cannot render confirmation email: %w", err) diff --git a/pkg/authz/service.go b/pkg/authz/service.go index b1b530a8a..8049ef27c 100644 --- a/pkg/authz/service.go +++ b/pkg/authz/service.go @@ -32,14 +32,14 @@ import ( type ( Service struct { pg *pg.Client - hostname string + baseURL string tokenSecret string invitationTokenValidity time.Duration } TenantAuthzService struct { pg *pg.Client - hostname string + baseURL string tokenSecret string invitationTokenValidity time.Duration scope coredata.Scoper @@ -62,13 +62,13 @@ const ( func NewService( ctx context.Context, pgClient *pg.Client, - hostname string, + baseURL string, tokenSecret string, invitationTokenValidity time.Duration, ) (*Service, error) { return &Service{ pg: pgClient, - hostname: hostname, + baseURL: baseURL, tokenSecret: tokenSecret, invitationTokenValidity: invitationTokenValidity, }, nil @@ -77,7 +77,7 @@ func NewService( func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService { return &TenantAuthzService{ pg: s.pg, - hostname: s.hostname, + baseURL: s.baseURL, tokenSecret: s.tokenSecret, invitationTokenValidity: s.invitationTokenValidity, scope: coredata.NewScope(tenantID), @@ -743,7 +743,7 @@ func (s *TenantAuthzService) InviteUserToOrganization( if userExists { recipientName = user.FullName - invitationURL = fmt.Sprintf("https://%s/", s.hostname) + invitationURL = s.baseURL + "/" } else { recipientName = fullName invitationData := coredata.InvitationData{ @@ -764,11 +764,11 @@ func (s *TenantAuthzService) InviteUserToOrganization( return fmt.Errorf("cannot generate invitation token: %w", err) } - invitationURL = fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName)) + invitationURL = fmt.Sprintf("%s/auth/signup-from-invitation?token=%s&fullName=%s", s.baseURL, invitationToken, url.QueryEscape(fullName)) } subject, textBody, htmlBody, err := emails.RenderInvitation( - s.hostname, + s.baseURL, recipientName, organization.Name, invitationURL, diff --git a/pkg/baseurl/baseurl.go b/pkg/baseurl/baseurl.go new file mode 100644 index 000000000..2263f7617 --- /dev/null +++ b/pkg/baseurl/baseurl.go @@ -0,0 +1,226 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package baseurl + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// BaseURL represents a validated base URL for the application. +// It provides convenient methods for building URLs with paths and query parameters. +type BaseURL struct { + raw string + parsed *url.URL +} + +// Parse creates a new BaseURL from a string, validating that it's a valid absolute URL. +func Parse(rawURL string) (*BaseURL, error) { + if rawURL == "" { + return nil, fmt.Errorf("base URL cannot be empty") + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + + if !parsed.IsAbs() { + return nil, fmt.Errorf("base URL must be absolute (include scheme)") + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("base URL scheme must be http or https, got: %s", parsed.Scheme) + } + + if parsed.Host == "" { + return nil, fmt.Errorf("base URL must include a host") + } + + return &BaseURL{ + raw: rawURL, + parsed: parsed, + }, nil +} + +// MustParse creates a new BaseURL from a string, panicking if it's invalid. +// This should only be used in tests or with known-valid URLs. +func MustParse(rawURL string) *BaseURL { + b, err := Parse(rawURL) + if err != nil { + panic(err) + } + return b +} + +// String returns the base URL as a string. +func (b *BaseURL) String() string { + if b == nil { + return "" + } + return b.raw +} + +// Scheme returns the URL scheme (http or https). +func (b *BaseURL) Scheme() string { + if b == nil || b.parsed == nil { + return "" + } + return b.parsed.Scheme +} + +// Host returns the host:port portion of the URL. +func (b *BaseURL) Host() string { + if b == nil || b.parsed == nil { + return "" + } + return b.parsed.Host +} + +// Hostname returns just the hostname without the port. +func (b *BaseURL) Hostname() string { + if b == nil || b.parsed == nil { + return "" + } + return b.parsed.Hostname() +} + +// Port returns the port portion of the URL, or empty string if not specified. +func (b *BaseURL) Port() string { + if b == nil || b.parsed == nil { + return "" + } + return b.parsed.Port() +} + +// URLBuilder provides a fluent interface for building URLs. +type URLBuilder struct { + base *BaseURL + path string + query url.Values + err error +} + +// WithPath returns a URLBuilder with the specified path. +// The path will be properly joined with the base URL. +func (b *BaseURL) WithPath(path string) *URLBuilder { + if b == nil { + return &URLBuilder{err: fmt.Errorf("base URL is nil")} + } + + // Ensure path starts with / + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + return &URLBuilder{ + base: b, + path: path, + query: make(url.Values), + } +} + +// WithQuery adds a query parameter to the URL. +func (ub *URLBuilder) WithQuery(key, value string) *URLBuilder { + if ub.err != nil { + return ub + } + ub.query.Add(key, value) + return ub +} + +// WithQueryValues sets multiple query parameters at once. +func (ub *URLBuilder) WithQueryValues(values url.Values) *URLBuilder { + if ub.err != nil { + return ub + } + for key, vals := range values { + for _, val := range vals { + ub.query.Add(key, val) + } + } + return ub +} + +// String builds and returns the final URL string. +func (ub *URLBuilder) String() (string, error) { + if ub.err != nil { + return "", ub.err + } + + u := &url.URL{ + Scheme: ub.base.Scheme(), + Host: ub.base.Host(), + Path: ub.path, + RawQuery: ub.query.Encode(), + } + + return u.String(), nil +} + +// MustString builds and returns the final URL string, panicking on error. +// This should only be used when you're certain the URL is valid. +func (ub *URLBuilder) MustString() string { + s, err := ub.String() + if err != nil { + panic(err) + } + return s +} + +// UnmarshalJSON implements json.Unmarshaler for BaseURL. +func (b *BaseURL) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + parsed, err := Parse(s) + if err != nil { + return err + } + + *b = *parsed + return nil +} + +// MarshalJSON implements json.Marshaler for BaseURL. +func (b *BaseURL) MarshalJSON() ([]byte, error) { + if b == nil { + return json.Marshal("") + } + return json.Marshal(b.raw) +} + +// UnmarshalText implements encoding.TextUnmarshaler for BaseURL. +func (b *BaseURL) UnmarshalText(text []byte) error { + parsed, err := Parse(string(text)) + if err != nil { + return err + } + + *b = *parsed + return nil +} + +// MarshalText implements encoding.TextMarshaler for BaseURL. +func (b *BaseURL) MarshalText() ([]byte, error) { + if b == nil { + return []byte(""), nil + } + return []byte(b.raw), nil +} diff --git a/pkg/baseurl/baseurl_test.go b/pkg/baseurl/baseurl_test.go new file mode 100644 index 000000000..a96bf70d5 --- /dev/null +++ b/pkg/baseurl/baseurl_test.go @@ -0,0 +1,243 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package baseurl + +import ( + "encoding/json" + "net/url" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid http URL", + input: "http://localhost:8080", + wantErr: false, + }, + { + name: "valid https URL", + input: "https://example.com", + wantErr: false, + }, + { + name: "valid https URL with port", + input: "https://example.com:8443", + wantErr: false, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "relative URL", + input: "/path/to/resource", + wantErr: true, + }, + { + name: "invalid scheme", + input: "ftp://example.com", + wantErr: true, + }, + { + name: "no host", + input: "http://", + wantErr: true, + }, + { + name: "invalid URL", + input: "ht!tp://invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got == nil { + t.Error("Parse() returned nil without error") + } + }) + } +} + +func TestBaseURL_Accessors(t *testing.T) { + b := MustParse("https://example.com:8443") + + if got := b.String(); got != "https://example.com:8443" { + t.Errorf("String() = %v, want %v", got, "https://example.com:8443") + } + + if got := b.Scheme(); got != "https" { + t.Errorf("Scheme() = %v, want %v", got, "https") + } + + if got := b.Host(); got != "example.com:8443" { + t.Errorf("Host() = %v, want %v", got, "example.com:8443") + } + + if got := b.Hostname(); got != "example.com" { + t.Errorf("Hostname() = %v, want %v", got, "example.com") + } + + if got := b.Port(); got != "8443" { + t.Errorf("Port() = %v, want %v", got, "8443") + } +} + +func TestBaseURL_WithPath(t *testing.T) { + b := MustParse("https://example.com") + + tests := []struct { + name string + path string + want string + }{ + { + name: "path with leading slash", + path: "/auth/login", + want: "https://example.com/auth/login", + }, + { + name: "path without leading slash", + path: "auth/login", + want: "https://example.com/auth/login", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := b.WithPath(tt.path).String() + if err != nil { + t.Errorf("WithPath().String() error = %v", err) + return + } + if got != tt.want { + t.Errorf("WithPath().String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBaseURL_WithQuery(t *testing.T) { + b := MustParse("https://example.com") + + got, err := b.WithPath("/search"). + WithQuery("q", "test"). + WithQuery("limit", "10"). + String() + + if err != nil { + t.Fatalf("WithPath().WithQuery().String() error = %v", err) + } + + // Parse the result to check query parameters + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("Failed to parse result URL: %v", err) + } + + if parsed.Query().Get("q") != "test" { + t.Errorf("Query param 'q' = %v, want %v", parsed.Query().Get("q"), "test") + } + + if parsed.Query().Get("limit") != "10" { + t.Errorf("Query param 'limit' = %v, want %v", parsed.Query().Get("limit"), "10") + } +} + +func TestBaseURL_WithQueryValues(t *testing.T) { + b := MustParse("https://example.com") + + values := url.Values{} + values.Add("foo", "bar") + values.Add("baz", "qux") + + got, err := b.WithPath("/test").WithQueryValues(values).String() + if err != nil { + t.Fatalf("WithPath().WithQueryValues().String() error = %v", err) + } + + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("Failed to parse result URL: %v", err) + } + + if parsed.Query().Get("foo") != "bar" { + t.Errorf("Query param 'foo' = %v, want %v", parsed.Query().Get("foo"), "bar") + } + + if parsed.Query().Get("baz") != "qux" { + t.Errorf("Query param 'baz' = %v, want %v", parsed.Query().Get("baz"), "qux") + } +} + +func TestBaseURL_JSON(t *testing.T) { + original := MustParse("https://example.com:8443") + + // Marshal + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + // Unmarshal + var restored BaseURL + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if restored.String() != original.String() { + t.Errorf("After JSON round-trip: got %v, want %v", restored.String(), original.String()) + } +} + +func TestBaseURL_NilSafety(t *testing.T) { + var b *BaseURL + + if got := b.String(); got != "" { + t.Errorf("nil.String() = %v, want empty string", got) + } + + if got := b.Scheme(); got != "" { + t.Errorf("nil.Scheme() = %v, want empty string", got) + } + + if got := b.Host(); got != "" { + t.Errorf("nil.Host() = %v, want empty string", got) + } + + if got := b.Hostname(); got != "" { + t.Errorf("nil.Hostname() = %v, want empty string", got) + } + + if got := b.Port(); got != "" { + t.Errorf("nil.Port() = %v, want empty string", got) + } + + builder := b.WithPath("/test") + if _, err := builder.String(); err == nil { + t.Error("nil.WithPath().String() expected error, got nil") + } +} diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index f21394269..4e17b03ed 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -457,9 +457,14 @@ func (s *DocumentService) SendSigningNotifications( return fmt.Errorf("cannot create signing request token: %w", err) } + baseURLParsed, err := url.Parse(s.svc.baseURL) + if err != nil { + return fmt.Errorf("cannot parse base URL: %w", err) + } + signRequestURL := url.URL{ - Scheme: "https", - Host: s.svc.hostname, + Scheme: baseURLParsed.Scheme, + Host: baseURLParsed.Host, Path: "/documents/signing-requests", RawQuery: url.Values{ "token": []string{token}, @@ -467,7 +472,7 @@ func (s *DocumentService) SendSigningNotifications( } subject, textBody, htmlBody, err := emails.RenderDocumentSigning( - s.svc.hostname, + s.svc.baseURL, people.FullName, organization.Name, signRequestURL.String(), @@ -1584,7 +1589,7 @@ func (s *DocumentService) SendExportEmail( } subject, textBody, htmlBody, err := emails.RenderDocumentExport( - s.svc.hostname, + s.svc.baseURL, recipientName, downloadURL, ) diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 2522330bd..7eba35a96 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -709,7 +709,7 @@ func (s FrameworkService) SendExportEmail( } subject, textBody, htmlBody, err := emails.RenderFrameworkExport( - s.svc.hostname, + s.svc.baseURL, recipientName, downloadURL, ) diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 51bcea4cc..d08c19d61 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -52,7 +52,7 @@ type ( s3 *s3.Client bucket string encryptionKey cipher.EncryptionKey - hostname string + baseURL string tokenSecret string trustConfig TrustConfig agentConfig agents.Config @@ -70,7 +70,7 @@ type ( bucket string encryptionKey cipher.EncryptionKey scope coredata.Scoper - hostname string + baseURL string tokenSecret string trustConfig TrustConfig agent *agents.Agent @@ -115,7 +115,7 @@ func NewService( pgClient *pg.Client, s3Client *s3.Client, bucket string, - hostname string, + baseURL string, tokenSecret string, trustConfig TrustConfig, agentConfig agents.Config, @@ -135,7 +135,7 @@ func NewService( s3: s3Client, bucket: bucket, encryptionKey: encryptionKey, - hostname: hostname, + baseURL: baseURL, tokenSecret: tokenSecret, trustConfig: trustConfig, agentConfig: agentConfig, @@ -156,7 +156,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { s3: s.s3, bucket: s.bucket, encryptionKey: s.encryptionKey, - hostname: s.hostname, + baseURL: s.baseURL, scope: coredata.NewScope(tenantID), tokenSecret: s.tokenSecret, trustConfig: s.trustConfig, diff --git a/pkg/probo/trust_center_access_service.go b/pkg/probo/trust_center_access_service.go index d53138be3..f97c752b4 100644 --- a/pkg/probo/trust_center_access_service.go +++ b/pkg/probo/trust_center_access_service.go @@ -442,7 +442,13 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con return fmt.Errorf("cannot load organization: %w", err) } - hostname := s.svc.hostname + baseURLParsed, err := url.Parse(s.svc.baseURL) + if err != nil { + return fmt.Errorf("cannot parse base URL: %w", err) + } + + hostname := baseURLParsed.Host + scheme := baseURLParsed.Scheme path := "/trust/" + trustCenter.Slug + "/access" if organization.CustomDomainID != nil { @@ -456,11 +462,12 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con } hostname = customDomain.Domain + scheme = "https" path = "/access" } accessURL := url.URL{ - Scheme: "https", + Scheme: scheme, Host: hostname, Path: path, RawQuery: url.Values{ @@ -489,7 +496,7 @@ func (s TrustCenterAccessService) sendTrustCenterAccessEmail( accessURL string, ) error { subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess( - s.svc.hostname, + s.svc.baseURL, name, companyName, accessURL, diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index fac77af5f..efdb1f5ca 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -31,6 +31,7 @@ import ( "github.com/getprobo/probo/pkg/auth" "github.com/getprobo/probo/pkg/authz" "github.com/getprobo/probo/pkg/awsconfig" + "github.com/getprobo/probo/pkg/baseurl" "github.com/getprobo/probo/pkg/certmanager" "github.com/getprobo/probo/pkg/connector" "github.com/getprobo/probo/pkg/coredata" @@ -64,7 +65,7 @@ type ( } config struct { - Hostname string `json:"hostname"` + BaseURL *baseurl.BaseURL `json:"base-url"` EncryptionKey cipher.EncryptionKey `json:"encryption-key"` Pg pgConfig `json:"pg"` Api apiConfig `json:"api"` @@ -93,7 +94,7 @@ var ( func New() *Implm { return &Implm{ cfg: config{ - Hostname: "localhost:8080", + BaseURL: baseurl.MustParse("http://localhost:8080"), Api: apiConfig{ Addr: "localhost:8080", }, @@ -275,8 +276,7 @@ func (impl *Implm) Run( impl.cfg.EncryptionKey, hp, impl.cfg.Auth.Cookie.Secret, - impl.cfg.Hostname, - fmt.Sprintf("https://%s", impl.cfg.Hostname), + impl.cfg.BaseURL.String(), impl.cfg.Auth.DisableSignup, time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second, ) @@ -287,7 +287,7 @@ func (impl *Implm) Run( authzService, err := authz.NewService( ctx, pgClient, - impl.cfg.Hostname, + impl.cfg.BaseURL.String(), impl.cfg.Auth.Cookie.Secret, time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second, ) @@ -300,7 +300,7 @@ func (impl *Implm) Run( samlService, err := auth.NewSAMLService( pgClient, impl.cfg.EncryptionKey, - fmt.Sprintf("https://%s", impl.cfg.Hostname), + impl.cfg.BaseURL.String(), impl.cfg.Auth.SAML.SessionDurationTime(), impl.cfg.Auth.Cookie.Name, impl.cfg.Auth.Cookie.Secret, @@ -347,7 +347,7 @@ func (impl *Implm) Run( pgClient, s3Client, impl.cfg.AWS.Bucket, - impl.cfg.Hostname, + impl.cfg.BaseURL.String(), impl.cfg.Auth.Cookie.Secret, trustConfig, agentConfig, @@ -366,7 +366,7 @@ func (impl *Implm) Run( pgClient, s3Client, impl.cfg.AWS.Bucket, - impl.cfg.Hostname, + impl.cfg.BaseURL.String(), impl.cfg.EncryptionKey, impl.cfg.TrustAuth.TokenSecret, impl.cfg.GetSlackSigningSecret(), @@ -392,7 +392,7 @@ func (impl *Implm) Run( SAML: samlService, ConnectorRegistry: defaultConnectorRegistry, Agent: agent, - SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname}, + SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.BaseURL.Host()}, CustomDomainCname: impl.cfg.CustomDomains.CnameTarget, FileManager: fileManagerService, PGClient: pgClient, diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index eb74ba23e..b8fd7da35 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -4997,12 +4997,12 @@ func (r *sAMLConfigurationResolver) SpMetadataURL(ctx context.Context, obj *type // TestLoginURL is the resolver for the testLoginUrl field. func (r *sAMLConfigurationResolver) TestLoginURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) { entityID := r.samlSvc.GetEntityID() - parts := strings.Split(entityID, "/auth/saml/metadata") + parts := strings.Split(entityID, "/connect/saml/metadata") if len(parts) != 2 { return "", fmt.Errorf("invalid entity ID format") } - return fmt.Sprintf("%s/auth/saml/login/%s", parts[0], obj.ID), nil + return fmt.Sprintf("%s/connect/saml/login/%s", parts[0], obj.ID), nil } // Organization is the resolver for the organization field. diff --git a/pkg/server/auth/list_organizations_handler.go b/pkg/server/auth/list_organizations_handler.go index e5caff35a..5f43b6830 100644 --- a/pkg/server/auth/list_organizations_handler.go +++ b/pkg/server/auth/list_organizations_handler.go @@ -56,7 +56,7 @@ func buildOrganizationResponse( // Generate logo URL path if organization has a logo var logoURL *string if org.LogoFileID != nil { - url := fmt.Sprintf("/auth/organizations/%s/logo", org.ID) + url := fmt.Sprintf("/connect/organizations/%s/logo", org.ID) logoURL = &url } @@ -74,11 +74,11 @@ func buildOrganizationResponse( case authsvc.AuthMethodSAML, authsvc.AuthMethodAny: orgResponse.AuthenticationMethod = "saml" if accessResult.SAMLConfig != nil { - orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", accessResult.SAMLConfig.ID) + orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", accessResult.SAMLConfig.ID) } case authsvc.AuthMethodPassword: orgResponse.AuthenticationMethod = "password" - orgResponse.LoginURL = "/authentication/login?method=password" + orgResponse.LoginURL = "/auth/login?method=password" } return orgResponse } @@ -88,13 +88,13 @@ func buildOrganizationResponse( if sessionData.PasswordAuthenticated { orgResponse.AuthenticationMethod = "password" - orgResponse.LoginURL = "/authentication/login?method=password" + orgResponse.LoginURL = "/auth/login?method=password" } else if samlInfo, ok := sessionData.SAMLAuthenticatedOrgs[org.ID.String()]; ok { orgResponse.AuthenticationMethod = "saml" - orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", samlInfo.SAMLConfigID) + orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", samlInfo.SAMLConfigID) } else { orgResponse.AuthenticationMethod = "any" - orgResponse.LoginURL = "/authentication/login?method=password" + orgResponse.LoginURL = "/auth/login?method=password" } return orgResponse diff --git a/pkg/server/server.go b/pkg/server/server.go index 4163746fb..ce25f0be4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -135,7 +135,7 @@ func NewServer(cfg Config) (*Server, error) { func (s *Server) setupRoutes() { s.router.Mount("/api", s.apiServer) - s.router.Mount("/auth", s.authServer) + s.router.Mount("/connect", s.authServer) s.router.Route("/trust/{slugOrId}", func(r chi.Router) { r.Use(s.loadTrustCenterBySlugOrID) diff --git a/pkg/trust/service.go b/pkg/trust/service.go index 050e31414..7f523da3d 100644 --- a/pkg/trust/service.go +++ b/pkg/trust/service.go @@ -45,7 +45,7 @@ type ( encryptionKey cipher.EncryptionKey tokenSecret string slackSigningSecret string - hostname string + baseURL string auth *auth.Service html2pdfConverter *html2pdf.Converter fileManager *filemanager.Service @@ -61,7 +61,7 @@ type ( proboSvc *probo.Service encryptionKey cipher.EncryptionKey tokenSecret string - hostname string + baseURL string auth *auth.Service html2pdfConverter *html2pdf.Converter fileManager *filemanager.Service @@ -85,7 +85,7 @@ func NewService( pgClient *pg.Client, s3Client *s3.Client, bucket string, - hostname string, + baseURL string, encryptionKey cipher.EncryptionKey, tokenSecret string, slackSigningSecret string, @@ -102,7 +102,7 @@ func NewService( encryptionKey: encryptionKey, tokenSecret: tokenSecret, slackSigningSecret: slackSigningSecret, - hostname: hostname, + baseURL: baseURL, auth: auth, html2pdfConverter: html2pdfConverter, fileManager: fileManagerService, @@ -120,7 +120,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { proboSvc: s.proboSvc, encryptionKey: s.encryptionKey, tokenSecret: s.tokenSecret, - hostname: s.hostname, + baseURL: s.baseURL, auth: s.auth, html2pdfConverter: s.html2pdfConverter, fileManager: s.fileManager, diff --git a/pkg/trust/slack_message_service.go b/pkg/trust/slack_message_service.go index 72f6a8236..fe2bc6ac8 100644 --- a/pkg/trust/slack_message_service.go +++ b/pkg/trust/slack_message_service.go @@ -22,6 +22,7 @@ import ( "fmt" "time" + "github.com/getprobo/probo/pkg/baseurl" "github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/gid" "github.com/getprobo/probo/pkg/slack" @@ -409,6 +410,11 @@ func (s *SlackMessageService) buildAccessRequestMessage( fileIDs = append(fileIDs, file.ID) } + base, err := baseurl.Parse(s.svc.baseURL) + if err != nil { + return nil, fmt.Errorf("cannot parse base URL: %w", err) + } + templateData := struct { RequesterName string RequesterEmail string @@ -425,7 +431,7 @@ func (s *SlackMessageService) buildAccessRequestMessage( RequesterName: requesterName, RequesterEmail: requesterEmail, OrganizationID: organizationID.String(), - Domain: s.svc.hostname, + Domain: base.Host(), SlackMessageID: slackMessageID.String(), DocumentIDs: documentIDs, ReportIDs: reportIDs, diff --git a/pkg/trust/trust_center_access_service.go b/pkg/trust/trust_center_access_service.go index 265d3695d..42eed0c09 100644 --- a/pkg/trust/trust_center_access_service.go +++ b/pkg/trust/trust_center_access_service.go @@ -470,7 +470,13 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co return fmt.Errorf("cannot load organization: %w", err) } - hostname := s.svc.hostname + baseURLParsed, err := url.Parse(s.svc.baseURL) + if err != nil { + return fmt.Errorf("cannot parse base URL: %w", err) + } + + hostname := baseURLParsed.Host + scheme := baseURLParsed.Scheme path := "/trust/" + trustCenter.Slug + "/access" if organization.CustomDomainID != nil { @@ -484,11 +490,12 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co } hostname = customDomain.Domain + scheme = "https" path = "/access" } accessURL := url.URL{ - Scheme: "https", + Scheme: scheme, Host: hostname, Path: path, RawQuery: url.Values{ @@ -517,7 +524,7 @@ func (s *TrustCenterAccessService) sendTrustCenterAccessEmail( accessURL string, ) error { subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess( - s.svc.hostname, + s.svc.baseURL, name, companyName, accessURL,