Skip to content

Commit 04b5f00

Browse files
authored
Merge pull request #356 from diggerhq/preview-url-auth
add preview url auth
2 parents 274525f + 28d9ec9 commit 04b5f00

20 files changed

Lines changed: 782 additions & 14 deletions

File tree

cmd/oc/internal/commands/preview.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,29 @@ var previewDeleteCmd = &cobra.Command{
113113
},
114114
}
115115

116+
// previewRotateAuthCmd issues a fresh bearer token for the sandbox's
117+
// edge-enforced preview-URL auth gate. The old token stops working
118+
// immediately — no dual-token grace period in v1.
119+
var previewRotateAuthCmd = &cobra.Command{
120+
Use: "rotate-auth <sandbox-id>",
121+
Short: "Rotate the sandbox's preview-URL bearer token",
122+
Args: cobra.ExactArgs(1),
123+
RunE: func(cmd *cobra.Command, args []string) error {
124+
c := client.FromContext(cmd.Context())
125+
var resp struct {
126+
PreviewAuthToken string `json:"previewAuthToken"`
127+
Scheme string `json:"scheme"`
128+
}
129+
if err := c.Post(cmd.Context(), fmt.Sprintf("/sandboxes/%s/preview/rotate", args[0]), nil, &resp); err != nil {
130+
return err
131+
}
132+
printer.Print(resp, func() {
133+
fmt.Printf("New preview auth token (shown once): %s\n", resp.PreviewAuthToken)
134+
})
135+
return nil
136+
},
137+
}
138+
116139
func init() {
117140
previewCreateCmd.Flags().Int("port", 0, "Container port to expose (required)")
118141
previewCreateCmd.Flags().String("domain", "", "Custom domain")
@@ -121,4 +144,5 @@ func init() {
121144
previewCmd.AddCommand(previewCreateCmd)
122145
previewCmd.AddCommand(previewListCmd)
123146
previewCmd.AddCommand(previewDeleteCmd)
147+
previewCmd.AddCommand(previewRotateAuthCmd)
124148
}

cmd/oc/internal/commands/sandbox.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ var sandboxCreateCmd = &cobra.Command{
2727
envSlice, _ := cmd.Flags().GetStringSlice("env")
2828
metaSlice, _ := cmd.Flags().GetStringSlice("metadata")
2929
secretStore, _ := cmd.Flags().GetString("secret-store")
30+
previewAuth, _ := cmd.Flags().GetBool("preview-auth")
31+
previewAuthToken, _ := cmd.Flags().GetString("preview-auth-token")
3032

3133
config := types.SandboxConfig{
3234
Timeout: timeout,
@@ -55,6 +57,20 @@ var sandboxCreateCmd = &cobra.Command{
5557
if secretStore != "" {
5658
body["secretStore"] = secretStore
5759
}
60+
// --preview-auth-token implies --preview-auth. Either flag attaches the
61+
// previewAuth block to the create payload; only the token-having flag
62+
// supplies a caller-chosen secret. The plaintext is returned exactly
63+
// once via the sandbox response's PreviewAuthToken field; print it
64+
// prominently so a piped/scripted caller can capture it.
65+
if previewAuth || previewAuthToken != "" {
66+
pa := map[string]string{"scheme": "bearer"}
67+
if previewAuthToken != "" {
68+
pa["token"] = previewAuthToken
69+
} else {
70+
pa["token"] = "auto"
71+
}
72+
body["previewAuth"] = pa
73+
}
5874

5975
var sandbox types.Sandbox
6076
if err := c.Post(cmd.Context(), "/sandboxes", body, &sandbox); err != nil {
@@ -63,6 +79,9 @@ var sandboxCreateCmd = &cobra.Command{
6379

6480
printer.Print(sandbox, func() {
6581
fmt.Printf("Created sandbox %s (status: %s)\n", sandbox.ID, sandbox.Status)
82+
if sandbox.PreviewAuthToken != "" {
83+
fmt.Printf("Preview auth token (shown once): %s\n", sandbox.PreviewAuthToken)
84+
}
6685
})
6786
return nil
6887
},
@@ -274,6 +293,8 @@ func init() {
274293
cmd.Flags().StringSlice("env", nil, "Environment variables (KEY=VALUE)")
275294
cmd.Flags().StringSlice("metadata", nil, "Metadata (KEY=VALUE)")
276295
cmd.Flags().String("secret-store", "", "Secret store name (injects encrypted secrets)")
296+
cmd.Flags().Bool("preview-auth", false, "Require a bearer token on the sandbox's preview URLs (server generates a 256-bit token, printed once)")
297+
cmd.Flags().String("preview-auth-token", "", "Bring your own preview-URL bearer token (>=16 chars); implies --preview-auth")
277298
}
278299

279300
// sandbox wake flags
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
title: 'Rotate Preview-URL Auth Token'
3+
api: 'POST /api/sandboxes/{id}/preview/rotate'
4+
---
5+
6+
Mint a new bearer token for the sandbox's preview-URL auth gate. The old token stops working immediately — there is no zero-downtime dual-token mode, so roll the new token out to your caller before discarding the old one.
7+
8+
If the sandbox was originally created without [`previewAuth`](/api-reference/sandboxes/create), this call installs a token and starts enforcing the gate from that point on.
9+
10+
The plaintext is returned exactly once in the response. Only the SHA-256 hash is persisted server-side; the server cannot show you the token again later.
11+
12+
<ParamField path="id" type="string" required>
13+
Sandbox ID
14+
</ParamField>
15+
16+
<ResponseExample>
17+
```json 200
18+
{
19+
"previewAuthToken": "qx2sSi5IYXWBvnnRqwK9Ky_cIAI-x0Vx1bPCt0XMxsI",
20+
"scheme": "bearer"
21+
}
22+
```
23+
</ResponseExample>
24+
25+
### Errors
26+
27+
| Status | When |
28+
| --- | --- |
29+
| `404` | Sandbox not found, or owned by a different org |
30+
| `410` | Sandbox is `stopped` or `error` |
31+
| `503` | Database not configured (combined-mode CP without PG) |

docs/api-reference/sandboxes/create.mdx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,27 @@ If both `cpuCount` and `memoryMB` are provided, they must match a platform tier.
4545
Name of a pre-built snapshot for instant boot
4646
</ParamField>
4747

48+
<ParamField body="previewAuth" type="object">
49+
Opt in to bearer-token authentication on the sandbox's preview URLs. When set, every request to `https://sb-<id>-p<port>.<domain>` must include an `Authorization: Bearer <token>` (or `X-OC-Preview-Token: <token>`) header; missing or wrong → 401.
50+
51+
- `scheme` *(string)*: must be `"bearer"`. Reserved for HMAC/JWT later.
52+
- `token` *(string)*: `"auto"` (or omitted) → server generates a 256-bit random token. An explicit string of at least 16 characters lets you bring your own.
53+
54+
The plaintext is returned exactly once in the response as `previewAuthToken`; only its SHA-256 hash is stored. Use [`POST /api/sandboxes/{id}/preview/rotate`](/api-reference/preview/rotate-auth) to mint a new one.
55+
56+
Omit this field for the legacy open behavior — preview URLs respond to anyone who can reach the hostname.
57+
</ParamField>
58+
4859
<ResponseExample>
4960
```json 201
5061
{
5162
"sandboxID": "sb-abc123",
5263
"status": "running",
5364
"region": "use2",
54-
"workerID": "w-use2-abc123"
65+
"workerID": "w-use2-abc123",
66+
"previewAuthToken": "qx2sSi5IYXWBvnnRqwK9Ky_cIAI-x0Vx1bPCt0XMxsI"
5567
}
5668
```
69+
70+
`previewAuthToken` is only present when `previewAuth` was set in the request. Read it once and store it durably — the server will not return it again.
5771
</ResponseExample>

docs/cli/preview.mdx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,37 @@ oc preview delete sb-abc123 3000
5757

5858
Preview URLs persist across hibernation/wake cycles — no need to re-create them.
5959

60+
## Bearer-Token Authentication
61+
62+
By default, anyone who knows the preview hostname can hit your sandbox's port. To require an `Authorization: Bearer <token>` header on every request, opt in at create time:
63+
64+
```bash
65+
oc sandbox create --preview-auth
66+
# Created sandbox sb-abc123 (status: running)
67+
# Preview auth token (shown once): qx2sSi5IYXWBvnnRqwK9Ky_cIAI-x0Vx1bPCt0XMxsI
68+
```
69+
70+
Then call your preview URL with the token:
71+
72+
```bash
73+
curl -H "Authorization: Bearer qx2sSi5IYX..." https://sb-abc123-p3000.workers.opencomputer.dev/
74+
```
75+
76+
Bring your own token if your gateway already has a shared secret:
77+
78+
```bash
79+
oc sandbox create --preview-auth-token "$GATEWAY_TOKEN"
80+
```
81+
82+
Rotate the token (old one stops working immediately):
83+
84+
```bash
85+
oc preview rotate-auth sb-abc123
86+
# New preview auth token (shown once): <new token>
87+
```
88+
89+
Token is shown exactly once and only its SHA-256 hash is stored. See [Authentication](/sandboxes/preview-urls#authentication) for the SDK equivalents.
90+
6091
<Tip>
6192
SDK usage: [Preview URLs](/sandboxes/preview-urls). Full flags: [CLI Reference](/reference/cli#oc-preview).
6293
</Tip>

docs/docs.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,8 @@
303303
"pages": [
304304
"api-reference/preview/create",
305305
"api-reference/preview/list",
306-
"api-reference/preview/delete"
306+
"api-reference/preview/delete",
307+
"api-reference/preview/rotate-auth"
307308
]
308309
},
309310
{

docs/reference/cli/preview.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,20 @@ Output columns: `PORT`, `HOSTNAME`, `SSL`, `CREATED`
3434
## `oc preview delete <sandbox-id> <port>`
3535

3636
[HTTP API →](/api-reference/preview/delete)
37+
38+
---
39+
40+
## `oc preview rotate-auth <sandbox-id>`
41+
42+
[HTTP API →](/api-reference/preview/rotate-auth)
43+
44+
Mint a new bearer token for the sandbox's preview URLs. The old token stops working immediately. The new plaintext is printed once — capture it before moving on; the server will not return it again.
45+
46+
```bash
47+
oc preview rotate-auth sb-abc
48+
# New preview auth token (shown once): qx2sSi5IYX...
49+
```
50+
51+
Calling this on a sandbox that was created without `--preview-auth` installs a token and starts enforcing the gate from that point on.
52+
53+
See [`oc sandbox create --preview-auth`](/reference/cli/sandbox#oc-sandbox-create) to enable at create time, or [`--preview-auth-token`](/reference/cli/sandbox#oc-sandbox-create) to bring your own.

docs/reference/cli/sandbox.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,18 @@ Create a new sandbox. **Alias:** `oc create`
2929
Metadata `KEY=VALUE` (repeatable)
3030
</ParamField>
3131

32+
<ParamField query="--preview-auth" type="bool">
33+
Require a bearer token on the sandbox's preview URLs. The server generates a 256-bit random token and prints it once. See [Preview-URL authentication](/sandboxes/preview-urls#authentication).
34+
</ParamField>
35+
36+
<ParamField query="--preview-auth-token" type="string">
37+
Bring your own preview-URL bearer token (≥16 characters). Implies `--preview-auth`. Useful when your gateway already has a shared secret.
38+
</ParamField>
39+
3240
```bash
3341
oc create --timeout 600 --cpu 2 --memory 1024 --env NODE_ENV=production
42+
oc create --preview-auth # server-generated token
43+
oc create --preview-auth-token "$GATEWAY_TOKEN" # bring your own
3444
```
3545

3646
---

docs/sandboxes/preview-urls.mdx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,115 @@ console.log(sandbox.getPreviewDomain(3000));
2323

2424
The `domain` and `getPreviewDomain(port)` properties construct the preview hostname directly — no API call needed. Traffic is routed through the control plane proxy to the correct sandbox.
2525

26+
## Authentication
27+
28+
Preview URLs are public by default — anyone who can guess or learn the hostname can hit your sandbox's port. To require a bearer token in front of every request, opt in at create time with `previewAuth`.
29+
30+
When enabled, the control plane validates an `Authorization: Bearer <token>` (or `X-OC-Preview-Token: <token>`) header on every preview-URL request. Missing or wrong → 401. The check happens before traffic ever reaches your sandbox, so a brute-force run also can't wake a hibernated sandbox.
31+
32+
### Enable at create time
33+
34+
<CodeGroup>
35+
36+
```typescript TypeScript
37+
const sandbox = await Sandbox.create({
38+
previewAuth: { scheme: "bearer", token: "auto" },
39+
});
40+
41+
// previewAuthToken is returned exactly once — store it somewhere durable.
42+
console.log(sandbox.previewAuthToken);
43+
// → "qx2sSi5IYXWBvnnRqwK9Ky_cIAI-x0Vx1bPCt0XMxsI"
44+
```
45+
46+
```python Python
47+
sandbox = await Sandbox.create(
48+
preview_auth={"scheme": "bearer", "token": "auto"},
49+
)
50+
51+
# preview_auth_token is returned exactly once — store it somewhere durable.
52+
print(sandbox.preview_auth_token)
53+
# → "qx2sSi5IYXWBvnnRqwK9Ky_cIAI-x0Vx1bPCt0XMxsI"
54+
```
55+
56+
```bash CLI
57+
oc sandbox create --preview-auth
58+
# Created sandbox sb-abc123 (status: running)
59+
# Preview auth token (shown once): qx2sSi5IYXWBvnnRqwK9Ky_cIAI-x0Vx1bPCt0XMxsI
60+
```
61+
62+
</CodeGroup>
63+
64+
`token: "auto"` (or omitting the field) tells the server to generate a 256-bit random token. To bring your own — useful when your gateway already has a secret it can share with both ends — pass an explicit string of at least 16 characters:
65+
66+
<CodeGroup>
67+
68+
```typescript TypeScript
69+
await Sandbox.create({
70+
previewAuth: { scheme: "bearer", token: process.env.GATEWAY_TOKEN! },
71+
});
72+
```
73+
74+
```python Python
75+
await Sandbox.create(
76+
preview_auth={"scheme": "bearer", "token": os.environ["GATEWAY_TOKEN"]},
77+
)
78+
```
79+
80+
```bash CLI
81+
oc sandbox create --preview-auth-token "$GATEWAY_TOKEN"
82+
```
83+
84+
</CodeGroup>
85+
86+
### Making authenticated requests
87+
88+
Send the token on every request to the preview URL. Two header forms are accepted; pick whichever fits your client:
89+
90+
```bash
91+
curl -H "Authorization: Bearer $TOKEN" https://sb-abc123-p3000.workers.opencomputer.dev/
92+
curl -H "X-OC-Preview-Token: $TOKEN" https://sb-abc123-p3000.workers.opencomputer.dev/
93+
```
94+
95+
The token gates **every port** on the sandbox — there's one token per sandbox, shared across all preview URLs you create on it.
96+
97+
### Rotation
98+
99+
When you need to roll the token — exposure, scheduled rotation, employee offboarding — call `rotate`. The old token stops working immediately and the new plaintext is returned exactly once:
100+
101+
<CodeGroup>
102+
103+
```typescript TypeScript
104+
const newToken = await sandbox.rotatePreviewAuthToken();
105+
// sandbox.previewAuthToken is also updated in place.
106+
```
107+
108+
```python Python
109+
new_token = await sandbox.rotate_preview_auth_token()
110+
# sandbox.preview_auth_token is also updated in place.
111+
```
112+
113+
```bash CLI
114+
oc preview rotate-auth sb-abc123
115+
# New preview auth token (shown once): <new token>
116+
```
117+
118+
</CodeGroup>
119+
120+
There is no zero-downtime dual-token mode in v1 — roll out the new token to your caller before discarding the old one, or expect a brief window of 401s during the swap.
121+
122+
### What's stored and what's not
123+
124+
- Only the **SHA-256 hash** of the token is persisted. The server never sees the plaintext after the create or rotate response returns.
125+
- There is no GET endpoint that returns the token. If you lose it, your only options are to rotate (mint a new one) or recreate the sandbox.
126+
- A sandbox created **without** `previewAuth` has open preview URLs — exactly the same as before this feature existed. The feature is fully opt-in and backwards compatible.
127+
128+
### Where the check happens
129+
130+
Authentication is enforced by the cell's control plane immediately before the request is proxied to the worker. That means:
131+
132+
- **Hibernated sandboxes** stay hibernated on missing/wrong tokens — bad tokens can't burn wake capacity.
133+
- **Self-hosted deployments** without the Cloudflare edge get the same gate for free — the check follows the sandbox, not the front door.
134+
26135
## Explicit Preview URLs
27136

28137
For tracking, custom domains, or auth configuration, use the preview URL API:

internal/api/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ func NewServer(mgr sandbox.Manager, ptyMgr *sandbox.PTYManager, apiKey string, o
442442
api.POST("/sandboxes/:id/preview", s.createPreviewURL)
443443
api.GET("/sandboxes/:id/preview", s.listPreviewURLs)
444444
api.DELETE("/sandboxes/:id/preview/:port", s.deletePreviewURL)
445+
api.POST("/sandboxes/:id/preview/rotate", s.rotateSandboxPreviewAuth)
445446

446447
// Data-plane routes: in server mode, proxy to workers; otherwise handle locally
447448
if s.sandboxAPIProxy != nil {

0 commit comments

Comments
 (0)