Skip to content

Commit f50505a

Browse files
jeremyederclaudeambient-code[bot]
authored
fix: cap access key token lifetime at 1 year, remove never-expire (#1141)
## Summary Re-fixes #1084. Kubernetes `TokenRequest` does not support non-expiring tokens — the API server silently caps `ExpirationSeconds`, so tokens expire regardless. The original PR offered a "No expiration" option that was misleading. - **Backend**: Require `expirationSeconds` (reject nil/≤0), reject values exceeding 1 year (31536000s) - **Frontend**: Remove "No expiration" option, extract shared `EXPIRATION_OPTIONS` to `lib/constants.ts`, use shadcn `Select` component - **Tests**: 6 backend Ginkgo tests for expiration validation ## Changes - `components/backend/handlers/permissions.go` — validate and enforce max 1 year - `components/backend/handlers/permissions_test.go` — expiration validation tests - `components/frontend/src/app/projects/[name]/keys/page.tsx` — use shared constants, shadcn Select - `components/frontend/src/lib/constants.ts` — shared `EXPIRATION_OPTIONS` and `DEFAULT_EXPIRATION` - `.gitignore` — add `.worktrees/` and `.claude/worktrees/` ## Test plan - [x] Frontend unit tests pass (613 passed) - [x] Backend unit tests pass (all packages) - [x] Backend expiration validation: rejects missing, zero, negative, >1yr; accepts 1yr and 90d - [x] Local kind cluster deployed and manually verified - [x] `tsc --noEmit` clean, all pre-commit hooks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Project keys now require an expiration time. * Predefined token lifetime options added with a 90-day default. * **Improvements** * Maximum token lifetime capped at 1 year. * Token lifetime selector is disabled while creating a key. * “No expiration” option and related helper text removed. * **Tests** * Added validation tests covering expiration presence, bounds, and accepted values. * **Chores** * Minor config wording and workflow tweaks; gitignore updated. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: ambient-code[bot] <ambient-code[bot]@users.noreply.github.com>
1 parent b6bffdb commit f50505a

7 files changed

Lines changed: 183 additions & 45 deletions

File tree

.coderabbit.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ reviews:
155155
3. Expensive work inside loops (API calls, JSON parsing, regex compilation).
156156
4. Unbounded growth: caches, watchers, buffers without eviction/limits.
157157
5. Missing pagination/limits on List operations or API endpoints.
158-
6. Frontend: unnecessary rerenders, missing memoization, unvirtualized large lists, missing dependency arrays, unbounded localStorage, sessionStorage or Cookies. Blocking HTTP requests.
158+
6. Frontend: unnecessary rerenders, missing memoization, unvirtualized large lists, missing dependency arrays, unbounded localStorage, sessionStorage or Cookies. Blocking HTTP requests.
159159
160160
Per issue: file, lines, risk, fix category. If clean, mark PASSED.
161161

.github/workflows/daily-sdk-update.yml

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ concurrency:
1818
jobs:
1919
update-sdk:
2020
name: Update claude-agent-sdk to latest
21+
if: github.event_name != 'pull_request'
2122
runs-on: ubuntu-latest
2223
timeout-minutes: 15
2324

@@ -158,33 +159,28 @@ jobs:
158159
159160
git push -u origin "$BRANCH"
160161
161-
PR_BODY=$(cat <<PREOF
162-
## Summary
163-
164-
- Updates \`claude-agent-sdk\` minimum version from \`>=${CURRENT}\` to \`>=${LATEST}\`
165-
- Files changed: \`pyproject.toml\` and \`uv.lock\`
166-
167-
## Release Info
168-
169-
PyPI: https://pypi.org/project/claude-agent-sdk/${LATEST}/
170-
171-
## Test Plan
172-
173-
- [ ] Runner tests pass (\`runner-tests\` workflow)
174-
- [ ] Container image builds successfully (\`components-build-deploy\` workflow)
175-
176-
> **Note:** PRs created by \`GITHUB_TOKEN\` do not automatically trigger \`pull_request\` workflows.
177-
> CI must be triggered manually (push an empty commit or re-run workflows) or the repo can be
178-
> configured with a PAT via \`secrets.BOT_TOKEN\` to enable automatic CI triggering.
179-
180-
---
181-
*Auto-generated by daily-sdk-update workflow*
182-
PREOF
183-
)
162+
printf '%s\n' \
163+
"## Summary" \
164+
"" \
165+
"- Updates \`claude-agent-sdk\` minimum version from \`>=${CURRENT}\` to \`>=${LATEST}\`" \
166+
"- Files changed: \`pyproject.toml\` and \`uv.lock\`" \
167+
"" \
168+
"## Release Info" \
169+
"" \
170+
"PyPI: https://pypi.org/project/claude-agent-sdk/${LATEST}/" \
171+
"" \
172+
"## Test Plan" \
173+
"" \
174+
"- [ ] Runner tests pass (\`runner-tests\` workflow)" \
175+
"- [ ] Container image builds successfully (\`components-build-deploy\` workflow)" \
176+
"" \
177+
"---" \
178+
"*Auto-generated by daily-sdk-update workflow*" \
179+
> /tmp/pr-body.md
184180
185181
gh pr create \
186182
--title "chore(runner): update claude-agent-sdk to >=${LATEST}" \
187-
--body "$PR_BODY" \
183+
--body-file /tmp/pr-body.md \
188184
--base main \
189185
--head "$BRANCH"
190186

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ dmypy.json
8888

8989
# Claude Code
9090
.claude/settings.local.json
91+
.claude/worktrees/
92+
93+
# Git worktrees
9194
.worktrees/
9295

9396
# mkdocs

components/backend/handlers/permissions.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,21 @@ func CreateProjectKey(c *gin.Context) {
389389
return
390390
}
391391

392+
// Validate and apply token expiration (required, max 1 year).
393+
// Kubernetes TokenRequest does not support non-expiring tokens — the API
394+
// server silently caps ExpirationSeconds and the token will expire even if
395+
// you omit the field (default ~1h). We enforce an explicit maximum of 1
396+
// year so users get predictable behaviour instead of a silent K8s default.
397+
const maxExpirationSeconds int64 = 31536000 // 1 year
398+
if req.ExpirationSeconds == nil || *req.ExpirationSeconds <= 0 {
399+
c.JSON(http.StatusBadRequest, gin.H{"error": "expirationSeconds is required and must be greater than 0"})
400+
return
401+
}
402+
if *req.ExpirationSeconds > maxExpirationSeconds {
403+
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("expirationSeconds must not exceed %d (1 year)", maxExpirationSeconds)})
404+
return
405+
}
406+
392407
// Create a dedicated ServiceAccount per key
393408
uid := uuid.New().String()[:8]
394409
saName := fmt.Sprintf("ambient-key-%s-%s", sanitizeName(req.Name), uid)
@@ -434,10 +449,9 @@ func CreateProjectKey(c *gin.Context) {
434449
return
435450
}
436451

437-
// Issue a one-time JWT token for this ServiceAccount (no audience; used as API key)
438-
tokenSpec := authnv1.TokenRequestSpec{}
439-
if req.ExpirationSeconds != nil && *req.ExpirationSeconds > 0 {
440-
tokenSpec.ExpirationSeconds = req.ExpirationSeconds
452+
// Generate token with validated expiration
453+
tokenSpec := authnv1.TokenRequestSpec{
454+
ExpirationSeconds: req.ExpirationSeconds,
441455
}
442456
tr := &authnv1.TokenRequest{Spec: tokenSpec}
443457
tok, err := k8sClient.CoreV1().ServiceAccounts(projectName).CreateToken(context.TODO(), saName, tr, v1.CreateOptions{})

components/backend/handlers/permissions_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,131 @@ var _ = Describe("Permissions Handler", Ordered, Label(test_constants.LabelUnit,
848848
})
849849
})
850850

851+
Context("CreateProjectKey Expiration Validation", func() {
852+
It("Should reject missing expirationSeconds", func() {
853+
requestBody := map[string]interface{}{
854+
"name": "test-key",
855+
"role": "edit",
856+
}
857+
858+
ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody)
859+
ginContext.Params = gin.Params{
860+
{Key: "projectName", Value: "test-project"},
861+
}
862+
httpUtils.SetAuthHeader("test-token")
863+
ginContext.Set("userID", "test-user")
864+
865+
CreateProjectKey(ginContext)
866+
867+
httpUtils.AssertHTTPStatus(http.StatusBadRequest)
868+
httpUtils.AssertErrorMessage("expirationSeconds is required")
869+
})
870+
871+
It("Should reject zero expirationSeconds", func() {
872+
requestBody := map[string]interface{}{
873+
"name": "test-key",
874+
"role": "edit",
875+
"expirationSeconds": 0,
876+
}
877+
878+
ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody)
879+
ginContext.Params = gin.Params{
880+
{Key: "projectName", Value: "test-project"},
881+
}
882+
httpUtils.SetAuthHeader("test-token")
883+
ginContext.Set("userID", "test-user")
884+
885+
CreateProjectKey(ginContext)
886+
887+
httpUtils.AssertHTTPStatus(http.StatusBadRequest)
888+
httpUtils.AssertErrorMessage("expirationSeconds is required")
889+
})
890+
891+
It("Should reject negative expirationSeconds", func() {
892+
requestBody := map[string]interface{}{
893+
"name": "test-key",
894+
"role": "edit",
895+
"expirationSeconds": -1,
896+
}
897+
898+
ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody)
899+
ginContext.Params = gin.Params{
900+
{Key: "projectName", Value: "test-project"},
901+
}
902+
httpUtils.SetAuthHeader("test-token")
903+
ginContext.Set("userID", "test-user")
904+
905+
CreateProjectKey(ginContext)
906+
907+
httpUtils.AssertHTTPStatus(http.StatusBadRequest)
908+
httpUtils.AssertErrorMessage("expirationSeconds is required")
909+
})
910+
911+
It("Should reject expirationSeconds exceeding 1 year", func() {
912+
requestBody := map[string]interface{}{
913+
"name": "test-key",
914+
"role": "edit",
915+
"expirationSeconds": 31536001,
916+
}
917+
918+
ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody)
919+
ginContext.Params = gin.Params{
920+
{Key: "projectName", Value: "test-project"},
921+
}
922+
httpUtils.SetAuthHeader("test-token")
923+
ginContext.Set("userID", "test-user")
924+
925+
CreateProjectKey(ginContext)
926+
927+
httpUtils.AssertHTTPStatus(http.StatusBadRequest)
928+
httpUtils.AssertErrorMessage("must not exceed 31536000")
929+
})
930+
931+
It("Should accept expirationSeconds at exactly 1 year", func() {
932+
requestBody := map[string]interface{}{
933+
"name": "test-key",
934+
"role": "edit",
935+
"expirationSeconds": 31536000,
936+
}
937+
938+
ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody)
939+
ginContext.Params = gin.Params{
940+
{Key: "projectName", Value: "test-project"},
941+
}
942+
httpUtils.SetAuthHeader("test-token")
943+
ginContext.Set("userID", "test-user")
944+
945+
CreateProjectKey(ginContext)
946+
947+
// Should pass validation and proceed to SA creation (not 400)
948+
status := httpUtils.GetResponseRecorder().Code
949+
Expect(status).NotTo(Equal(http.StatusBadRequest),
950+
"Valid 1-year expiration should not be rejected")
951+
})
952+
953+
It("Should accept valid 90-day expirationSeconds", func() {
954+
requestBody := map[string]interface{}{
955+
"name": "test-key",
956+
"role": "edit",
957+
"expirationSeconds": 7776000,
958+
}
959+
960+
ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody)
961+
ginContext.Params = gin.Params{
962+
{Key: "projectName", Value: "test-project"},
963+
}
964+
httpUtils.SetAuthHeader("test-token")
965+
ginContext.Set("userID", "test-user")
966+
967+
CreateProjectKey(ginContext)
968+
969+
// Should pass validation and proceed to SA creation (not 400)
970+
status := httpUtils.GetResponseRecorder().Code
971+
Expect(status).NotTo(Equal(http.StatusBadRequest),
972+
"Valid 90-day expiration should not be rejected")
973+
})
974+
})
975+
851976
Context("Resource Label Verification", func() {
852977
It("Should create resources with proper ambient-code labels", func() {
853978
requestBody := map[string]interface{}{

components/frontend/src/app/projects/[name]/keys/page.tsx

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,7 @@ import { useKeys, useCreateKey, useDeleteKey } from '@/services/queries';
2121
import { toast } from 'sonner';
2222
import type { CreateKeyRequest } from '@/services/api/keys';
2323
import { ROLE_DEFINITIONS } from '@/lib/role-colors';
24-
25-
const EXPIRATION_OPTIONS = [
26-
{ value: '86400', label: '1 day' },
27-
{ value: '604800', label: '7 days' },
28-
{ value: '2592000', label: '30 days' },
29-
{ value: '7776000', label: '90 days' },
30-
{ value: '31536000', label: '1 year' },
31-
{ value: 'none', label: 'No expiration' },
32-
] as const;
33-
34-
const DEFAULT_EXPIRATION = '7776000'; // 90 days
24+
import { EXPIRATION_OPTIONS, DEFAULT_EXPIRATION } from '@/lib/constants';
3525

3626
export default function ProjectKeysPage() {
3727
const params = useParams();
@@ -60,7 +50,7 @@ export default function ProjectKeysPage() {
6050
name: newKeyName.trim(),
6151
description: newKeyDesc.trim() || undefined,
6252
role: newKeyRole,
63-
expirationSeconds: newKeyExpiration !== 'none' ? Number(newKeyExpiration) : undefined,
53+
expirationSeconds: Number(newKeyExpiration),
6454
};
6555

6656
createKeyMutation.mutate(
@@ -303,7 +293,7 @@ export default function ProjectKeysPage() {
303293
</div>
304294
<div className="space-y-2">
305295
<Label htmlFor="key-expiration">Token Lifetime</Label>
306-
<Select value={newKeyExpiration} onValueChange={setNewKeyExpiration}>
296+
<Select value={newKeyExpiration} onValueChange={setNewKeyExpiration} disabled={createKeyMutation.isPending}>
307297
<SelectTrigger className="w-full">
308298
<SelectValue placeholder="Select lifetime" />
309299
</SelectTrigger>
@@ -315,9 +305,6 @@ export default function ProjectKeysPage() {
315305
))}
316306
</SelectContent>
317307
</Select>
318-
<p className="text-xs text-muted-foreground">
319-
How long the token remains valid. Choose &quot;No expiration&quot; for long-lived service keys.
320-
</p>
321308
</div>
322309
</div>
323310
<DialogFooter>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,15 @@
11
export const INACTIVITY_TIMEOUT_TOOLTIP =
22
"The session is stopped when no activity (user messages) is detected for this duration. The countdown starts from the last activity time, or from session start if there is no interaction. When set to 0, auto-stop is disabled entirely.";
3+
4+
// Kubernetes TokenRequest does not support non-expiring tokens — the API server
5+
// silently caps ExpirationSeconds. Max is 1 year; "No expiration" is not offered
6+
// because K8s will expire the token regardless.
7+
export const EXPIRATION_OPTIONS = [
8+
{ value: '86400', label: '1 day' },
9+
{ value: '604800', label: '7 days' },
10+
{ value: '2592000', label: '30 days' },
11+
{ value: '7776000', label: '90 days' },
12+
{ value: '31536000', label: '1 year' },
13+
] as const;
14+
15+
export const DEFAULT_EXPIRATION = '7776000'; // 90 days

0 commit comments

Comments
 (0)