Skip to content

Commit cbee905

Browse files
authored
Merge pull request #77 from ory/feat/session-cookie-domain-path
feat: add ory_custom_domain resource for managing custom domains
2 parents 46a24b7 + eb3020c commit cbee905

File tree

9 files changed

+852
-0
lines changed

9 files changed

+852
-0
lines changed

docs/resources/custom_domain.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
page_title: "ory_custom_domain Resource - ory"
3+
subcategory: ""
4+
description: |-
5+
Manages an Ory Network custom domain.
6+
---
7+
8+
# ory_custom_domain (Resource)
9+
10+
Manages an Ory Network custom domain.
11+
12+
Custom domains allow you to expose Ory APIs on your own domain (e.g., `auth.example.com`) instead of the default
13+
`{slug}.projects.oryapis.com` domain. This is the correct way to configure session cookie domains on Ory Network —
14+
the reverse proxy rewrites cookies to use the `cookie_domain` you specify.
15+
16+
-> **Plan:** Custom domains may require specific Ory Network plan features. Check your plan's quota for available custom domains.
17+
18+
~> **DNS Required:** After creating a custom domain, you must add a CNAME record pointing your hostname to Ory.
19+
Monitor the `verification_status` attribute to track DNS verification progress.
20+
21+
## Example Usage
22+
23+
```terraform
24+
# Basic custom domain
25+
resource "ory_custom_domain" "auth" {
26+
hostname = "auth.example.com"
27+
cookie_domain = "example.com"
28+
}
29+
30+
# Custom domain with CORS and custom UI
31+
resource "ory_custom_domain" "full" {
32+
hostname = "auth.example.com"
33+
cookie_domain = "example.com"
34+
35+
cors_enabled = true
36+
cors_allowed_origins = ["https://app.example.com", "https://admin.example.com"]
37+
custom_ui_base_url = "https://app.example.com/auth"
38+
}
39+
```
40+
41+
## Import
42+
43+
Custom domains can be imported using either format:
44+
45+
```shell
46+
# Import with explicit project ID
47+
terraform import ory_custom_domain.auth <project-id>/<custom-domain-id>
48+
49+
# Import using provider-level project_id
50+
terraform import ory_custom_domain.auth <custom-domain-id>
51+
```
52+
53+
<!-- schema generated by tfplugindocs -->
54+
## Schema
55+
56+
### Required
57+
58+
- `hostname` (String) The custom hostname where the API will be exposed (e.g., 'auth.example.com').
59+
60+
### Optional
61+
62+
- `cookie_domain` (String) The domain where session cookies will be set. Must be a parent domain of the hostname (e.g., 'example.com' for hostname 'auth.example.com').
63+
- `cors_allowed_origins` (List of String) CORS allowed origins for the custom hostname (max 50).
64+
- `cors_enabled` (Boolean) Whether CORS is enabled for the custom hostname.
65+
- `custom_ui_base_url` (String) The base URL where the custom user interface is exposed (e.g., 'https://app.example.com/auth').
66+
- `project_id` (String) The project ID. If not set, uses the provider's project_id.
67+
68+
### Read-Only
69+
70+
- `created_at` (String) Timestamp when the custom domain was created.
71+
- `id` (String) The unique identifier of the custom domain.
72+
- `ssl_status` (String) SSL certificate status of the custom domain.
73+
- `updated_at` (String) Timestamp when the custom domain was last updated.
74+
- `verification_errors` (List of String) DNS verification errors, if any.
75+
- `verification_status` (String) DNS verification status of the custom domain (e.g., 'pending', 'active').
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Basic custom domain
2+
resource "ory_custom_domain" "auth" {
3+
hostname = "auth.example.com"
4+
cookie_domain = "example.com"
5+
}
6+
7+
# Custom domain with CORS and custom UI
8+
resource "ory_custom_domain" "full" {
9+
hostname = "auth.example.com"
10+
cookie_domain = "example.com"
11+
12+
cors_enabled = true
13+
cors_allowed_origins = ["https://app.example.com", "https://admin.example.com"]
14+
custom_ui_base_url = "https://app.example.com/auth"
15+
}

internal/client/client.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package client
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
78
"fmt"
89
"io"
910
"net/http"
11+
"net/url"
1012
"strings"
1113
"sync"
1214
"time"
@@ -15,6 +17,9 @@ import (
1517
"github.com/ory/x/urlx"
1618
)
1719

20+
// ErrCustomDomainNotFound is returned when a custom domain ID is not found in the project's domain list.
21+
var ErrCustomDomainNotFound = errors.New("custom domain not found")
22+
1823
const (
1924
// maxRetries is the maximum number of retry attempts for rate-limited requests.
2025
maxRetries = 3
@@ -1282,6 +1287,148 @@ func (c *OryClient) ListIdentitySchemas(ctx context.Context) ([]ory.IdentitySche
12821287
return schemas, nil
12831288
}
12841289

1290+
// Custom Domain (CNAME) operations
1291+
// The Ory SDK does not generate API methods for custom domains,
1292+
// so we use raw HTTP calls against the console API.
1293+
1294+
// consoleHTTPDo executes a raw HTTP request against the console API.
1295+
func (c *OryClient) consoleHTTPDo(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
1296+
if c.config.WorkspaceAPIKey == "" || c.config.ConsoleAPIURL == "" {
1297+
return nil, fmt.Errorf("workspace_api_key and console_api_url must be configured for custom domain operations")
1298+
}
1299+
baseURL, err := url.Parse(c.config.ConsoleAPIURL)
1300+
if err != nil {
1301+
return nil, fmt.Errorf("invalid console_api_url %q: %w", c.config.ConsoleAPIURL, err)
1302+
}
1303+
requestURL := baseURL.JoinPath(path)
1304+
req, err := http.NewRequestWithContext(ctx, method, requestURL.String(), body)
1305+
if err != nil {
1306+
return nil, err
1307+
}
1308+
req.Header.Set("Authorization", "Bearer "+c.config.WorkspaceAPIKey)
1309+
if body != nil {
1310+
req.Header.Set("Content-Type", "application/json")
1311+
}
1312+
httpClient := http.DefaultClient
1313+
if c.consoleClient != nil {
1314+
if cfg := c.consoleClient.GetConfig(); cfg.HTTPClient != nil {
1315+
httpClient = cfg.HTTPClient
1316+
}
1317+
}
1318+
return httpClient.Do(req) // #nosec G704 -- URL is constructed from trusted provider configuration
1319+
}
1320+
1321+
// ListCustomDomains lists all custom domains for a project.
1322+
func (c *OryClient) ListCustomDomains(ctx context.Context, projectID string) ([]ory.CustomDomain, error) {
1323+
httpResp, err := c.consoleHTTPDo(ctx, http.MethodGet, "/projects/"+projectID+"/cname", nil)
1324+
if err != nil {
1325+
return nil, wrapAPIError(err, "listing custom domains")
1326+
}
1327+
defer httpResp.Body.Close()
1328+
1329+
if httpResp.StatusCode != http.StatusOK {
1330+
respBody, _ := io.ReadAll(httpResp.Body)
1331+
return nil, wrapAPIError(
1332+
fmt.Errorf("unexpected status %d: %s", httpResp.StatusCode, string(respBody)),
1333+
"listing custom domains",
1334+
)
1335+
}
1336+
1337+
var domains []ory.CustomDomain
1338+
if err := json.NewDecoder(httpResp.Body).Decode(&domains); err != nil {
1339+
return nil, fmt.Errorf("listing custom domains: decoding response: %w", err)
1340+
}
1341+
return domains, nil
1342+
}
1343+
1344+
// GetCustomDomain gets a custom domain by ID from the list.
1345+
func (c *OryClient) GetCustomDomain(ctx context.Context, projectID, domainID string) (*ory.CustomDomain, error) {
1346+
domains, err := c.ListCustomDomains(ctx, projectID)
1347+
if err != nil {
1348+
return nil, err
1349+
}
1350+
for i := range domains {
1351+
if domains[i].GetId() == domainID {
1352+
return &domains[i], nil
1353+
}
1354+
}
1355+
return nil, fmt.Errorf("%w: %s in project %s", ErrCustomDomainNotFound, domainID, projectID)
1356+
}
1357+
1358+
// CreateCustomDomain creates a new custom domain for a project.
1359+
func (c *OryClient) CreateCustomDomain(ctx context.Context, projectID string, body ory.CreateCustomDomainBody) (*ory.CustomDomain, error) {
1360+
bodyBytes, err := json.Marshal(body)
1361+
if err != nil {
1362+
return nil, fmt.Errorf("creating custom domain: marshaling body: %w", err)
1363+
}
1364+
1365+
httpResp, err := c.consoleHTTPDo(ctx, http.MethodPost, "/projects/"+projectID+"/cname", bytes.NewReader(bodyBytes))
1366+
if err != nil {
1367+
return nil, wrapAPIError(err, "creating custom domain")
1368+
}
1369+
defer httpResp.Body.Close()
1370+
1371+
if httpResp.StatusCode != http.StatusCreated {
1372+
respBody, _ := io.ReadAll(httpResp.Body)
1373+
return nil, wrapAPIError(
1374+
fmt.Errorf("unexpected status %d: %s", httpResp.StatusCode, string(respBody)),
1375+
"creating custom domain",
1376+
)
1377+
}
1378+
1379+
var domain ory.CustomDomain
1380+
if err := json.NewDecoder(httpResp.Body).Decode(&domain); err != nil {
1381+
return nil, fmt.Errorf("creating custom domain: decoding response: %w", err)
1382+
}
1383+
return &domain, nil
1384+
}
1385+
1386+
// UpdateCustomDomain updates an existing custom domain.
1387+
func (c *OryClient) UpdateCustomDomain(ctx context.Context, projectID, domainID string, body ory.SetCustomDomainBody) (*ory.CustomDomain, error) {
1388+
bodyBytes, err := json.Marshal(body)
1389+
if err != nil {
1390+
return nil, fmt.Errorf("updating custom domain: marshaling body: %w", err)
1391+
}
1392+
1393+
httpResp, err := c.consoleHTTPDo(ctx, http.MethodPut, "/projects/"+projectID+"/cname/"+domainID, bytes.NewReader(bodyBytes))
1394+
if err != nil {
1395+
return nil, wrapAPIError(err, "updating custom domain")
1396+
}
1397+
defer httpResp.Body.Close()
1398+
1399+
if httpResp.StatusCode != http.StatusOK {
1400+
respBody, _ := io.ReadAll(httpResp.Body)
1401+
return nil, wrapAPIError(
1402+
fmt.Errorf("unexpected status %d: %s", httpResp.StatusCode, string(respBody)),
1403+
"updating custom domain",
1404+
)
1405+
}
1406+
1407+
var domain ory.CustomDomain
1408+
if err := json.NewDecoder(httpResp.Body).Decode(&domain); err != nil {
1409+
return nil, fmt.Errorf("updating custom domain: decoding response: %w", err)
1410+
}
1411+
return &domain, nil
1412+
}
1413+
1414+
// DeleteCustomDomain deletes a custom domain.
1415+
func (c *OryClient) DeleteCustomDomain(ctx context.Context, projectID, domainID string) error {
1416+
httpResp, err := c.consoleHTTPDo(ctx, http.MethodDelete, "/projects/"+projectID+"/cname/"+domainID, nil)
1417+
if err != nil {
1418+
return wrapAPIError(err, "deleting custom domain")
1419+
}
1420+
defer httpResp.Body.Close()
1421+
1422+
if httpResp.StatusCode != http.StatusNoContent {
1423+
respBody, _ := io.ReadAll(httpResp.Body)
1424+
return wrapAPIError(
1425+
fmt.Errorf("unexpected status %d: %s", httpResp.StatusCode, string(respBody)),
1426+
"deleting custom domain",
1427+
)
1428+
}
1429+
return nil
1430+
}
1431+
12851432
// ListWorkspaces lists all workspaces.
12861433
func (c *OryClient) ListWorkspaces(ctx context.Context) ([]ory.Workspace, error) {
12871434
resp, httpResp, err := c.consoleClient.WorkspaceAPI.ListWorkspaces(ctx).Execute()

internal/provider/provider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
projectds "github.com/ory/terraform-provider-ory/internal/datasources/project"
2020
workspaceds "github.com/ory/terraform-provider-ory/internal/datasources/workspace"
2121
"github.com/ory/terraform-provider-ory/internal/resources/action"
22+
"github.com/ory/terraform-provider-ory/internal/resources/customdomain"
2223
"github.com/ory/terraform-provider-ory/internal/resources/emailtemplate"
2324
"github.com/ory/terraform-provider-ory/internal/resources/eventstream"
2425
"github.com/ory/terraform-provider-ory/internal/resources/identity"
@@ -289,6 +290,7 @@ func (p *OryProvider) Resources(ctx context.Context) []func() resource.Resource
289290
eventstream.NewResource,
290291
trustedjwtissuer.NewResource,
291292
oidcdynamicclient.NewResource,
293+
customdomain.NewResource,
292294
}
293295
}
294296

0 commit comments

Comments
 (0)