diff --git a/e2e/internal/testutil/client.go b/e2e/internal/testutil/client.go index 16f0ef351..80a8bee9e 100644 --- a/e2e/internal/testutil/client.go +++ b/e2e/internal/testutil/client.go @@ -24,9 +24,11 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "io" "net/http" "net/http/cookiejar" "net/url" + "strings" "testing" "time" @@ -53,16 +55,19 @@ const ( ) type Client struct { - T testing.TB - httpClient *http.Client - baseURL string - mailpitBaseURL string - role TestRole - userID gid.GID - profileID gid.GID - organizationID gid.GID - email string - password string + T testing.TB + httpClient *http.Client + proboHTTPClient *http.Client + trustClient *http.Client + trustHost string + baseURL string + mailpitBaseURL string + role TestRole + userID gid.GID + profileID gid.GID + organizationID gid.GID + email string + password string } func NewClient(t testing.TB, role TestRole) *Client { @@ -544,11 +549,7 @@ func NewClientWithNewSession(t testing.TB, from *Client) *Client { return client } -// SelfProvisionTrustCenterVisitor signs a brand-new email up as a trust -// center visitor through the public magic-link flow (send + verify), the -// same path a real visitor takes. It returns a Client bound to that -// visitor's own session and cookie jar, scoped to no organization. -func SelfProvisionTrustCenterVisitor(t testing.TB, trustCenterID string) *Client { +func SelfProvisionTrustCenterVisitor(t testing.TB, trustHost string) *Client { t.Helper() jar, err := cookiejar.New(nil) @@ -561,47 +562,170 @@ func SelfProvisionTrustCenterVisitor(t testing.TB, trustCenterID string) *Client baseURL: GetBaseURL(), mailpitBaseURL: GetMailpitBaseURL(), email: email, + trustHost: trustHost, httpClient: &http.Client{ Jar: jar, Timeout: 30 * time.Second, }, + trustClient: trustHTTPClientWithJar(trustHost, jar), + proboHTTPClient: &http.Client{ + Jar: jar, + Timeout: 30 * time.Second, + }, } - visitor.sendMagicLink(trustCenterID, email) - token := visitor.pollForLinkToken(fmt.Sprintf("to:%s", email)) - visitor.verifyMagicLink(trustCenterID, token) + visitor.connectViaCIMD(email) return visitor } -func (c *Client) sendMagicLink(trustCenterID, email string) { - const query = ` - mutation($input: SendMagicLinkInput!) { - sendMagicLink(input: $input) { - success - } - } - ` +func (c *Client) connectViaCIMD(email string) { + c.T.Helper() - err := c.ExecuteTrust(trustCenterID, query, map[string]any{ - "input": map[string]any{"email": email}, - }, nil) - require.NoError(c.T, err, "sendMagicLink mutation failed") + WaitForTrustCenterHTTPS(c.T, c.trustHost) + + initiateURL := fmt.Sprintf( + "https://%s/initiate?continue=/overview", + c.trustHost, + ) + + authorizeURL := c.redirectLocation(c.trustClient, initiateURL) + require.NotEmpty(c.T, authorizeURL, "oauth initiate must redirect to authorize") + + portalLoginURL := c.redirectLocation(c.proboHTTPClient, authorizeURL) + require.Contains(c.T, portalLoginURL, "/auth/portal-login", "unauthenticated authorize must redirect to portal login") + + authorizeParam := extractAuthorizeQueryParam(portalLoginURL) + require.NotEmpty(c.T, authorizeParam) + + c.postConnectMagicLink(email, authorizeParam) + + token := c.pollForLinkToken(fmt.Sprintf("to:%s", email)) + verifyURL := c.baseURL + "/api/connect/v1/magic-link/verify?token=" + url.QueryEscape(token) + + resumeAuthorizeURL := c.redirectLocation(c.proboHTTPClient, verifyURL) + require.Contains(c.T, resumeAuthorizeURL, "/api/connect/v1/oauth2/authorize") + + authorizeResp := c.redirectHTTPResponse(c.proboHTTPClient, resumeAuthorizeURL) + require.False( + c.T, + IsConsentRedirect(authorizeResp), + "compliance portal CIMD must skip oauth consent screen", + ) + require.Equal(c.T, http.StatusFound, authorizeResp.StatusCode) + + _, err := OAuth2AuthorizeCodeFromRedirect(authorizeResp) + require.NoError(c.T, err, "compliance portal CIMD must issue authorization code without consent") + + callbackURL := resolveRedirectURL(resumeAuthorizeURL, authorizeResp.Header.Get("Location")) + require.Contains(c.T, callbackURL, "/callback") + + finalURL := c.redirectLocation(c.trustClient, callbackURL) + require.True( + c.T, + strings.HasSuffix(finalURL, "/overview") || strings.Contains(finalURL, "/overview"), + "oauth callback must redirect to continue URL, got %q", + finalURL, + ) } -func (c *Client) verifyMagicLink(trustCenterID, token string) { - const query = ` - mutation($input: VerifyMagicLinkInput!) { - verifyMagicLink(input: $input) { - continue - } - } - ` +func (c *Client) postConnectMagicLink(email, authorizeParam string) { + c.T.Helper() - err := c.ExecuteTrust(trustCenterID, query, map[string]any{ - "input": map[string]any{"token": token}, - }, nil) - require.NoError(c.T, err, "verifyMagicLink mutation failed") + body := url.Values{} + body.Set("email", email) + body.Set("authorize", authorizeParam) + + req, err := http.NewRequest( + "POST", + c.baseURL+"/api/connect/v1/magic-link/send", + strings.NewReader(body.Encode()), + ) + require.NoError(c.T, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.proboHTTPClient.Do(req) + require.NoError(c.T, err, "magic-link send request failed") + + defer func() { _ = resp.Body.Close() }() + + require.Equal(c.T, http.StatusNoContent, resp.StatusCode, "magic-link send must return 204") +} + +func (c *Client) redirectLocation(client *http.Client, rawURL string) string { + c.T.Helper() + + resp := c.redirectHTTPResponse(client, rawURL) + require.True( + c.T, + resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest, + "expected redirect from %s, got %d", + rawURL, + resp.StatusCode, + ) + + location := resp.Header.Get("Location") + require.NotEmpty(c.T, location, "redirect from %s missing Location header", rawURL) + + return resolveRedirectURL(rawURL, location) +} + +func (c *Client) redirectHTTPResponse(client *http.Client, rawURL string) *OAuth2HTTPResponse { + c.T.Helper() + + noRedirectClient := &http.Client{ + Jar: client.Jar, + Timeout: client.Timeout, + Transport: client.Transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + require.NoError(c.T, err) + + resp, err := noRedirectClient.Do(req) + require.NoError(c.T, err, "request to %s failed", rawURL) + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + require.NoError(c.T, err) + + return &OAuth2HTTPResponse{ + StatusCode: resp.StatusCode, + Header: resp.Header, + Body: body, + } +} + +func resolveRedirectURL(baseURL, location string) string { + locURL, err := url.Parse(location) + if err != nil { + return location + } + + if locURL.IsAbs() { + return locURL.String() + } + + base, err := url.Parse(baseURL) + if err != nil { + return location + } + + return base.ResolveReference(locURL).String() +} + +func extractAuthorizeQueryParam(portalLoginURL string) string { + parsed, err := url.Parse(portalLoginURL) + if err != nil { + return "" + } + + return parsed.Query().Get("authorize") } func (c *Client) GetEmail() string { diff --git a/e2e/internal/testutil/graphql.go b/e2e/internal/testutil/graphql.go index 0ec8be32e..ca2524ec2 100644 --- a/e2e/internal/testutil/graphql.go +++ b/e2e/internal/testutil/graphql.go @@ -219,9 +219,14 @@ func ConsoleGraphQLWithAccessToken( // page's host as TLS SNI. Certificates are Pebble-issued for e2e, so // verification is skipped. func trustHTTPClient(serverName string) *http.Client { + return trustHTTPClientWithJar(serverName, nil) +} + +func trustHTTPClientWithJar(serverName string, jar http.CookieJar) *http.Client { dialer := &net.Dialer{Timeout: 5 * time.Second} return &http.Client{ + Jar: jar, Timeout: 30 * time.Second, Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { @@ -235,6 +240,36 @@ func trustHTTPClient(serverName string) *http.Client { } } +// WaitForTrustCenterHTTPS blocks until the dedicated trust-center listener +// serves the page over TLS. Managed domains provision certificates +// asynchronously after activation. +func WaitForTrustCenterHTTPS(t testing.TB, host string) { + t.Helper() + + client := TrustHTTPClient(host) + + require.Eventually( + t, + func() bool { + resp, err := client.Get("https://" + host + complianceportalOAuthMetadataPath()) + if err != nil { + return false + } + + defer func() { _ = resp.Body.Close() }() + + return resp.StatusCode == http.StatusOK + }, + 30*time.Second, + 500*time.Millisecond, + "trust center did not become servable on the dedicated listener", + ) +} + +func complianceportalOAuthMetadataPath() string { + return "/.well-known/oauth-client-metadata" +} + // DoTrust posts a GraphQL query to a compliance page served on the dedicated // listener. host is the page's serving domain (a customer custom domain or a // managed {slug}.probopage.localhost subdomain). @@ -249,7 +284,7 @@ func (c *Client) DoTrust(host string, query string, variables map[string]any) (* return nil, fmt.Errorf("cannot marshal request: %w", err) } - endpoint := fmt.Sprintf("https://%s/api/trust/v1/graphql", host) + endpoint := fmt.Sprintf("https://%s/graphql", host) req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { @@ -258,7 +293,12 @@ func (c *Client) DoTrust(host string, query string, variables map[string]any) (* req.Header.Set("Content-Type", "application/json") - resp, err := trustHTTPClient(host).Do(req) + client := trustHTTPClient(host) + if c.trustClient != nil && host == c.trustHost { + client = c.trustClient + } + + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } @@ -353,6 +393,10 @@ func (c *Client) BaseURL() string { return c.baseURL } +func TrustHTTPClient(trustHost string) *http.Client { + return trustHTTPClient(trustHost) +} + type UploadFile struct { Filename string ContentType string diff --git a/e2e/trust/helpers_test.go b/e2e/trust/helpers_test.go index f01d4cc78..fedc28a3b 100644 --- a/e2e/trust/helpers_test.go +++ b/e2e/trust/helpers_test.go @@ -21,6 +21,7 @@ package trust_test import ( + "net/url" "testing" "github.com/stretchr/testify/require" @@ -58,6 +59,42 @@ func lookupTrustCenterID(t *testing.T, owner *testutil.Client) string { return result.Node.TrustCenter.ID } +func lookupTrustHost(t *testing.T, owner *testutil.Client, trustCenterID string) string { + t.Helper() + + activateTrustCenter(t, owner, trustCenterID) + + const query = ` + query($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + trustCenter { publicUrl } + } + } + } + ` + + var result struct { + Node struct { + TrustCenter struct { + PublicURL string `json:"publicUrl"` + } `json:"trustCenter"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + }, &result) + require.NoError(t, err) + require.NotEmpty(t, result.Node.TrustCenter.PublicURL) + + publicURL, err := url.Parse(result.Node.TrustCenter.PublicURL) + require.NoError(t, err) + require.NotEmpty(t, publicURL.Host) + + return publicURL.Host +} + // activateTrustCenter flips the trust center to active so its public surface // (NDA, subprocessors, reports, branding) becomes reachable by visitors. func activateTrustCenter(t *testing.T, owner *testutil.Client, trustCenterID string) { diff --git a/e2e/trust/trust_center_nda_signature_test.go b/e2e/trust/trust_center_nda_signature_test.go index 30a8e2e8e..675abb885 100644 --- a/e2e/trust/trust_center_nda_signature_test.go +++ b/e2e/trust/trust_center_nda_signature_test.go @@ -60,12 +60,12 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin trustCenterID := lookupTrustCenterID(t, owner) uploadTrustCenterNDA(t, owner, trustCenterID) - activateTrustCenter(t, owner, trustCenterID) + trustHost := lookupTrustHost(t, owner, trustCenterID) - victim := testutil.SelfProvisionTrustCenterVisitor(t, trustCenterID) - attacker := testutil.SelfProvisionTrustCenterVisitor(t, trustCenterID) + victim := testutil.SelfProvisionTrustCenterVisitor(t, trustHost) + attacker := testutil.SelfProvisionTrustCenterVisitor(t, trustHost) - victimSignatureID, victimSignatureStatus := viewerSignature(t, victim, trustCenterID) + victimSignatureID, victimSignatureStatus := viewerSignature(t, victim, trustHost) require.NotEmpty(t, victimSignatureID) require.Equal(t, "PENDING", victimSignatureStatus) @@ -77,7 +77,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin } ` - err := attacker.ExecuteTrust(trustCenterID, acceptMutation, map[string]any{ + err := attacker.ExecuteTrust(trustHost, acceptMutation, map[string]any{ "input": map[string]any{"signatureId": victimSignatureID}, }, nil) require.Error(t, err, "attacker must not be able to accept another visitor's signature") @@ -91,7 +91,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin } ` - err = attacker.ExecuteTrust(trustCenterID, recordEventMutation, map[string]any{ + err = attacker.ExecuteTrust(trustHost, recordEventMutation, map[string]any{ "input": map[string]any{ "signatureId": victimSignatureID, "eventType": "DOCUMENT_VIEWED", @@ -100,7 +100,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin require.Error(t, err, "attacker must not be able to inject events into another visitor's signature") assertForbidden(t, err) - _, statusAfterAttack := viewerSignature(t, victim, trustCenterID) + _, statusAfterAttack := viewerSignature(t, victim, trustHost) assert.Equal(t, "PENDING", statusAfterAttack, "attack attempts must not have mutated the victim's signature") var acceptResult struct { @@ -112,7 +112,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin } `json:"acceptElectronicSignature"` } - err = victim.ExecuteTrust(trustCenterID, acceptMutation, map[string]any{ + err = victim.ExecuteTrust(trustHost, acceptMutation, map[string]any{ "input": map[string]any{"signatureId": victimSignatureID}, }, &acceptResult) require.NoError(t, err, "the legitimate signer must still be able to accept their own signature") @@ -147,7 +147,7 @@ func uploadTrustCenterNDA(t *testing.T, owner *testutil.Client, trustCenterID st require.NoError(t, err) } -func viewerSignature(t *testing.T, visitor *testutil.Client, trustCenterID string) (string, string) { +func viewerSignature(t *testing.T, visitor *testutil.Client, trustHost string) (string, string) { t.Helper() const query = ` @@ -171,7 +171,7 @@ func viewerSignature(t *testing.T, visitor *testutil.Client, trustCenterID strin } `json:"currentTrustCenter"` } - err := visitor.ExecuteTrust(trustCenterID, query, nil, &result) + err := visitor.ExecuteTrust(trustHost, query, nil, &result) require.NoError(t, err) sig := result.CurrentTrustCenter.NonDisclosureAgreement.ViewerSignature diff --git a/e2e/trust/trust_center_oauth_connect_test.go b/e2e/trust/trust_center_oauth_connect_test.go new file mode 100644 index 000000000..d3c9c8360 --- /dev/null +++ b/e2e/trust/trust_center_oauth_connect_test.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 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 trust_test + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestTrustCenter_CIMDMetadataDocument(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + trustCenterID := lookupTrustCenterID(t, owner) + trustHost := lookupTrustHost(t, owner, trustCenterID) + testutil.WaitForTrustCenterHTTPS(t, trustHost) + + client := testutil.TrustHTTPClient(trustHost) + resp, err := client.Get("https://" + trustHost + "/.well-known/oauth-client-metadata") + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var doc struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + } + require.NoError(t, json.Unmarshal(body, &doc)) + + assert.Equal( + t, + "https://"+trustHost+"/.well-known/oauth-client-metadata", + doc.ClientID, + ) + assert.Equal( + t, + []string{"https://" + trustHost + "/callback"}, + doc.RedirectURIs, + ) + assert.Equal(t, "none", doc.TokenEndpointAuthMethod) + assert.Contains(t, doc.GrantTypes, "authorization_code") + assert.Equal(t, []string{"code"}, doc.ResponseTypes) + assert.NotEmpty(t, doc.ClientName) +} + +func TestTrustCenter_VisitorConnectViaCIMD(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + trustCenterID := lookupTrustCenterID(t, owner) + trustHost := lookupTrustHost(t, owner, trustCenterID) + + visitor := testutil.SelfProvisionTrustCenterVisitor(t, trustHost) + + const query = ` + query { + currentTrustCenter { + title + } + } + ` + + var result struct { + CurrentTrustCenter struct { + Title string `json:"title"` + } `json:"currentTrustCenter"` + } + + err := visitor.ExecuteTrust(trustHost, query, nil, &result) + require.NoError(t, err, "visitor session must authenticate trust GraphQL after CIMD connect") + assert.NotEmpty(t, result.CurrentTrustCenter.Title) +} + +func TestTrustCenter_UnknownCIMDClientRejected(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + _, challenge := testutil.GeneratePKCE() + + clientID := "https://unknown-cimd.example.com/.well-known/oauth-client-metadata" + + resp, err := testutil.OAuth2Authorize(owner, url.Values{ + "client_id": {clientID}, + "redirect_uri": {"https://unknown-cimd.example.com/callback"}, + "response_type": {"code"}, + "scope": {"openid profile email"}, + "state": {"rejected"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "nonce": {"test-nonce"}, + }) + require.NoError(t, err) + + require.False( + t, + testutil.IsConsentRedirect(resp), + "unknown CIMD client must not reach consent screen", + ) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var oauthErr struct { + Code string `json:"error"` + } + require.NoError(t, json.Unmarshal(resp.Body, &oauthErr)) + assert.Equal(t, "invalid_client", oauthErr.Code) +}