Skip to content

Commit a60ce7d

Browse files
committed
Add two-tier discovery: public bootstrap and authenticated endpoints
Implement content-addressable discovery URLs with two tiers: - Bootstrap (/d/bootstrap → /d/<hash>): no auth, returns OIDC config - Discovery (/d/current → /d/<hash>): requires auth, returns match patterns Changes: - Rename OIDCConfig.Audience to ClientID (breaking config change) - Add BootstrapAuth/BootstrapHash methods to PolicyRulesConfig - Update discovery handler to serve both bootstrap and discovery - Add bootstrap Link header to CA GET / response - Add GetPublicKey/GetBootstrap to caclient for bootstrap flow - Add AuthConfigToCommand to convert bootstrap config to auth command Includes comprehensive unit tests and end-to-end integration test.
1 parent 0a35fd5 commit a60ce7d

File tree

16 files changed

+1246
-65
lines changed

16 files changed

+1246
-65
lines changed
Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ yatl_version: 1
33
title: 'Two-tier discovery: public and authenticated endpoints'
44
id: c1kdc4e1
55
created: 2025-12-24T23:08:50.420228Z
6-
updated: 2025-12-24T23:09:16.320617Z
6+
updated: 2026-01-03T00:47:24.678182Z
77
author: Brian McCallister
88
priority: medium
99
tags:
@@ -88,3 +88,23 @@ Authenticated discovery:
8888
### Open Items
8989

9090
- Need to add client_id to OIDC config (currently only has issuer and audience)
91+
92+
---
93+
# Log: 2026-01-02T23:56:52Z Brian McCallister
94+
95+
Started working.
96+
97+
---
98+
# Log: 2026-01-03T00:36:11Z Brian McCallister
99+
100+
-
101+
102+
---
103+
# Log: 2026-01-03T00:47:20Z Brian McCallister
104+
105+
Implemented two-tier discovery. Changes: (1) Renamed OIDCConfig.Audience to ClientID, (2) Added BootstrapHash/BootstrapAuth methods, (3) Updated discovery handler for bootstrap+discovery tiers, (4) Added bootstrap Link header to CA GET /, (5) Added Bootstrap/GetPublicKey/GetBootstrap to caclient, (6) Added AuthConfigToCommand function. All tests pass.
106+
107+
---
108+
# Log: 2026-01-03T00:47:24Z Brian McCallister
109+
110+
Closed: Implemented two-tier discovery: public bootstrap (no auth) and authenticated discovery. All 7 implementation steps complete with tests.

.tasks/open/hv3622e5.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
yatl_version: 1
3+
title: Explore eliminating client_secret from bootstrap - use PKCE public client flow
4+
id: hv3622e5
5+
created: 2026-01-03T00:35:10.650363Z
6+
updated: 2026-01-03T00:35:10.650363Z
7+
author: Brian McCallister
8+
priority: low
9+
tags:
10+
- exploration
11+
- oidc
12+
---
13+
14+
---
15+
# Log: 2026-01-03T00:35:10Z Brian McCallister
16+
17+
Created task.

CLAUDE.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,40 @@
44

55
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
66

7+
## Correctness over convenience
8+
9+
* Model the full error space—no shortcuts or simplified error handling.
10+
* Handle all edge cases, including race conditions, signal timing, and platform differences.
11+
* Use the type system to encode correctness constraints.
12+
* Prefer compile-time guarantees over runtime checks where possible.
13+
14+
## User experience as a primary driver
15+
16+
* Provide structured, helpful error messages for diagnostics
17+
* Make progress reporting responsive and informative.
18+
* Maintain consistency across platforms even when underlying OS capabilities differ. Use OS-native logic rather than trying to emulate Unix on Windows (or vice versa).
19+
* Write user-facing messages in clear, present tense: "Epithet now supports..." not "Epithet now supported..."
20+
21+
## Pragmatic incrementalism
22+
23+
* "Not overly generic"—prefer specific, composable logic over abstract frameworks.
24+
* Evolve the design incrementally rather than attempting perfect upfront architecture.
25+
* Document design decisions and trade-offs in design docs
26+
* When uncertain, explore and iterate
27+
28+
## Production-grade engineering
29+
30+
* Test comprehensively, including edge cases, race conditions, and stress tests.
31+
* Pay attention to what facilities already exist, and aim to reuse them.
32+
* Getting the details right is really important!
33+
34+
## Documentation
35+
36+
* Use inline comments to explain "why," not just "what".
37+
* Module-level documentation should explain purpose and responsibilities.
38+
* Always use periods at the end of code comments.
39+
* Never use title case in headings and titles. Always use sentence case.
40+
741
## Project Overview
842

943
Epithet is an SSH certificate authority system that makes SSH certificates easy to use. The project is currently undergoing a v2 rewrite (see README.md for v2 architecture details).

cmd/epithet/policy.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
// This is embedded in PolicyServerCLI to enable nested config paths like policy.oidc.issuer
2424
type PolicyOIDCConfig struct {
2525
Issuer string `help:"OIDC issuer URL" name:"issuer"`
26-
Audience string `help:"OIDC audience (client ID)" name:"audience"`
26+
ClientID string `help:"OIDC client ID" name:"client-id"`
2727
}
2828

2929
// PolicyServerCLI defines the CLI flags for the policy server.
@@ -74,7 +74,7 @@ func (c *PolicyServerCLI) Run(logger *slog.Logger, tlsCfg tlsconfig.Config, unif
7474
"users", len(cfg.Users),
7575
"hosts", len(cfg.Hosts),
7676
"oidc_issuer", cfg.OIDC.Issuer,
77-
"oidc_audience", cfg.OIDC.Audience)
77+
"oidc_client_id", cfg.OIDC.ClientID)
7878

7979
// Create policy evaluator and token validator
8080
ctx := context.Background()
@@ -101,7 +101,9 @@ func (c *PolicyServerCLI) Run(logger *slog.Logger, tlsCfg tlsconfig.Config, unif
101101
discoveryHandler := policyserver.NewDiscoveryHandler(policyserver.DiscoveryConfig{
102102
Validator: validator,
103103
MatchPatterns: matchPatterns,
104-
Hash: cfg.DiscoveryHash(),
104+
DiscoveryHash: cfg.DiscoveryHash(),
105+
BootstrapHash: cfg.BootstrapHash(),
106+
AuthConfig: cfg.BootstrapAuth(),
105107
})
106108

107109
// Set up router with middleware
@@ -113,15 +115,18 @@ func (c *PolicyServerCLI) Run(logger *slog.Logger, tlsCfg tlsconfig.Config, unif
113115
r.Use(middleware.Timeout(60 * time.Second))
114116

115117
r.Post("/", handler)
116-
// Redirect endpoint: /d/current -> /d/{hash} (cached 5 min)
118+
// Bootstrap redirect endpoint: /d/bootstrap -> /d/{bootstrap-hash} (cached 5 min, no auth)
119+
r.Get("/d/bootstrap", policyserver.NewBootstrapRedirectHandler(cfg.BootstrapHash(), c.DiscoveryBaseURL))
120+
// Discovery redirect endpoint: /d/current -> /d/{discovery-hash} (cached 5 min, requires auth)
117121
r.Get("/d/current", policyserver.NewDiscoveryRedirectHandler(cfg.DiscoveryHash(), c.DiscoveryBaseURL))
118-
// Content-addressed endpoint: /d/{hash} (immutable)
122+
// Content-addressed endpoint: /d/{hash} (immutable, serves bootstrap or discovery based on hash)
119123
r.Get("/d/*", discoveryHandler)
120124

121125
logger.Info("starting policy server",
122126
"listen", c.Listen,
123127
"ca_pubkey_length", len(caPubkey),
124128
"discovery_hash", cfg.DiscoveryHash(),
129+
"bootstrap_hash", cfg.BootstrapHash(),
125130
"match_patterns", matchPatterns)
126131

127132
return http.ListenAndServe(c.Listen, r)
@@ -162,8 +167,8 @@ func (c *PolicyServerCLI) applyOverrides(cfg *policyserver.PolicyRulesConfig) {
162167
if c.OIDC.Issuer != "" {
163168
cfg.OIDC.Issuer = c.OIDC.Issuer
164169
}
165-
if c.OIDC.Audience != "" {
166-
cfg.OIDC.Audience = c.OIDC.Audience
170+
if c.OIDC.ClientID != "" {
171+
cfg.OIDC.ClientID = c.OIDC.ClientID
167172
}
168173
if c.DefaultExpiration != "" {
169174
if cfg.Defaults == nil {

pkg/broker/auth.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import (
77
"io"
88
"os"
99
"os/exec"
10+
"strings"
1011
"sync"
1112

1213
"github.com/cbroglie/mustache"
14+
"github.com/epithet-ssh/epithet/pkg/caclient"
1315
)
1416

1517
// MaxStateBlobSize is the maximum size of the state blob (10 MiB).
@@ -132,3 +134,51 @@ func (h *Auth) Run(attrs any) (string, error) {
132134

133135
return h.token, nil
134136
}
137+
138+
// AuthConfigToCommand converts a bootstrap auth config to an executable command string.
139+
// For type="oidc": constructs "<executable> auth oidc --issuer X --client-id Y --scopes Z"
140+
// For type="command": returns the command as-is (substituting "epithet" with os.Executable())
141+
// Returns an error if the auth type is unknown or if os.Executable() fails.
142+
func AuthConfigToCommand(auth caclient.BootstrapAuth) (string, error) {
143+
switch auth.Type {
144+
case "oidc":
145+
// Construct the OIDC auth command
146+
executable, err := os.Executable()
147+
if err != nil {
148+
return "", fmt.Errorf("failed to get executable path: %w", err)
149+
}
150+
151+
// Build command: <executable> auth oidc --issuer X --client-id Y --scopes A,B,C
152+
parts := []string{
153+
executable,
154+
"auth", "oidc",
155+
"--issuer", auth.Issuer,
156+
"--client-id", auth.ClientID,
157+
}
158+
159+
if len(auth.Scopes) > 0 {
160+
parts = append(parts, "--scopes", strings.Join(auth.Scopes, ","))
161+
}
162+
163+
return strings.Join(parts, " "), nil
164+
165+
case "command":
166+
// Use the command as-is, substituting "epithet" with the current executable
167+
if auth.Command == "" {
168+
return "", fmt.Errorf("command auth type requires non-empty command")
169+
}
170+
171+
executable, err := os.Executable()
172+
if err != nil {
173+
return "", fmt.Errorf("failed to get executable path: %w", err)
174+
}
175+
176+
// Replace "epithet" with the actual executable path
177+
// This allows bootstrap configs to use "epithet" as a placeholder
178+
cmd := strings.ReplaceAll(auth.Command, "epithet", executable)
179+
return cmd, nil
180+
181+
default:
182+
return "", fmt.Errorf("unknown auth type: %s", auth.Type)
183+
}
184+
}

pkg/broker/auth_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"testing"
88

9+
"github.com/epithet-ssh/epithet/pkg/caclient"
910
"github.com/stretchr/testify/require"
1011
)
1112

@@ -375,3 +376,99 @@ printf '\x80\x81\x82token\xff\xfe'
375376
require.NoError(t, err)
376377
require.Equal(t, []byte{0x80, 0x81, 0x82, 't', 'o', 'k', 'e', 'n', 0xff, 0xfe}, decoded)
377378
}
379+
380+
func TestAuthConfigToCommand_OIDC(t *testing.T) {
381+
t.Parallel()
382+
383+
auth := caclient.BootstrapAuth{
384+
Type: "oidc",
385+
Issuer: "https://accounts.google.com",
386+
ClientID: "my-client-id",
387+
Scopes: []string{"openid", "profile", "email"},
388+
}
389+
390+
cmd, err := AuthConfigToCommand(auth)
391+
require.NoError(t, err)
392+
393+
// Command should contain the executable path (we can't predict exact path)
394+
require.Contains(t, cmd, "auth oidc")
395+
require.Contains(t, cmd, "--issuer https://accounts.google.com")
396+
require.Contains(t, cmd, "--client-id my-client-id")
397+
require.Contains(t, cmd, "--scopes openid,profile,email")
398+
}
399+
400+
func TestAuthConfigToCommand_OIDC_NoScopes(t *testing.T) {
401+
t.Parallel()
402+
403+
auth := caclient.BootstrapAuth{
404+
Type: "oidc",
405+
Issuer: "https://example.com",
406+
ClientID: "test-client",
407+
Scopes: nil,
408+
}
409+
410+
cmd, err := AuthConfigToCommand(auth)
411+
require.NoError(t, err)
412+
413+
// Command should not contain --scopes when empty
414+
require.Contains(t, cmd, "auth oidc")
415+
require.Contains(t, cmd, "--issuer https://example.com")
416+
require.Contains(t, cmd, "--client-id test-client")
417+
require.NotContains(t, cmd, "--scopes")
418+
}
419+
420+
func TestAuthConfigToCommand_Command(t *testing.T) {
421+
t.Parallel()
422+
423+
auth := caclient.BootstrapAuth{
424+
Type: "command",
425+
Command: "/usr/local/bin/custom-auth --tenant prod",
426+
}
427+
428+
cmd, err := AuthConfigToCommand(auth)
429+
require.NoError(t, err)
430+
431+
// Command should be passed through as-is
432+
require.Equal(t, "/usr/local/bin/custom-auth --tenant prod", cmd)
433+
}
434+
435+
func TestAuthConfigToCommand_Command_WithEpithetSubstitution(t *testing.T) {
436+
t.Parallel()
437+
438+
auth := caclient.BootstrapAuth{
439+
Type: "command",
440+
Command: "epithet auth oidc --issuer https://example.com",
441+
}
442+
443+
cmd, err := AuthConfigToCommand(auth)
444+
require.NoError(t, err)
445+
446+
// "epithet" should be replaced with actual executable path
447+
require.NotContains(t, cmd, "epithet auth")
448+
require.Contains(t, cmd, "auth oidc --issuer https://example.com")
449+
}
450+
451+
func TestAuthConfigToCommand_Command_Empty(t *testing.T) {
452+
t.Parallel()
453+
454+
auth := caclient.BootstrapAuth{
455+
Type: "command",
456+
Command: "",
457+
}
458+
459+
_, err := AuthConfigToCommand(auth)
460+
require.Error(t, err)
461+
require.Contains(t, err.Error(), "non-empty command")
462+
}
463+
464+
func TestAuthConfigToCommand_UnknownType(t *testing.T) {
465+
t.Parallel()
466+
467+
auth := caclient.BootstrapAuth{
468+
Type: "saml",
469+
}
470+
471+
_, err := AuthConfigToCommand(auth)
472+
require.Error(t, err)
473+
require.Contains(t, err.Error(), "unknown auth type: saml")
474+
}

0 commit comments

Comments
 (0)