Skip to content

Commit 4e06c27

Browse files
committed
controlplane: make the OIDC re-auth cadence an operator decision
One interactive OIDC login produced a session refreshable for a hardcoded 90 days: refresh needs only the previous biscuit and the node key's challenge signature, never the issuer, so an identity disabled at the provider kept its seat until the constant said otherwise. Since enrollment binding checks (fc9636b) and identity bans (8ae3063), the mesh has its own revocation levers, but how long the issuer's word is trusted without renewal is a policy trade-off, not a constant: shorter keeps the provider authoritative for offboarding, longer keeps headless nodes unattended. Add --oidc-session-ttl (default unchanged, 90 days) and cap the enrollment biscuit at the session boundary, as refresh already did. Pinned by TestOIDCSessionTTLIsConfigurable: the record and first biscuit honor the configured TTL, and a lapsed session refuses a correctly signed refresh with 401. Part of GHSA-cgmg-9xf6-rrgw (claim: 1h OIDC token yields a 90-day mesh credential).
1 parent 1283046 commit 4e06c27

6 files changed

Lines changed: 115 additions & 4 deletions

File tree

‎api/network.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ const (
6565
// This is verified locally by each peer on every connection.
6666
BiscuitTokenTTL = 24 * time.Hour
6767

68-
// OIDCSessionTTL is the maximum database-enforced lifespan of a node's OIDC
68+
// OIDCSessionTTL is the default database-enforced lifespan of a node's OIDC
6969
// interactive enrollment session (90 days). After this period, the node
7070
// must re-authenticate with the OIDC provider to establish a new session.
71+
// Operators tune the cadence with the control plane's --oidc-session-ttl.
7172
OIDCSessionTTL = 90 * 24 * time.Hour
7273

7374
// TokenRefreshCheckInterval is the frequency at which the node daemon and router check

‎cmd/sam-control-plane/main.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ var (
4242
keyGracePeriod time.Duration
4343
leaseDuration time.Duration
4444
biscuitTTL time.Duration
45+
oidcSessionTTL time.Duration
4546
adminTokenPath string
4647
insecureSkipTLSVerify bool
4748
logLevel string
@@ -122,6 +123,7 @@ func main() {
122123
InsecureSkipTLSVerify: insecureSkipTLSVerify,
123124
BiscuitTimeout: 10 * time.Second,
124125
BiscuitTTL: biscuitTTL,
126+
OIDCSessionTTL: oidcSessionTTL,
125127
AdminToken: adminToken,
126128
AutoApproveEnrollment: autoApproveEnrollment,
127129
}
@@ -156,6 +158,7 @@ func main() {
156158
rootCmd.Flags().DurationVar(&keyGracePeriod, "key-grace-period", 1*time.Hour, "Key grace period for rotated keys.")
157159
rootCmd.Flags().DurationVar(&leaseDuration, "lease-duration", 15*time.Minute, "Router lease registration TTL.")
158160
rootCmd.Flags().DurationVar(&biscuitTTL, "biscuit-ttl", api.BiscuitTokenTTL, "Lifespan minted into every issued Biscuit's expiration fact. Capped to the OIDC token's own expiry when shorter.")
161+
rootCmd.Flags().DurationVar(&oidcSessionTTL, "oidc-session-ttl", api.OIDCSessionTTL, "How long an OIDC enrollment stays refreshable before the identity must re-authenticate with the OIDC provider. Shorter values keep the provider authoritative for offboarding at the cost of more frequent interactive re-enrollment.")
159162
rootCmd.Flags().StringVar(&adminTokenPath, "admin-token-path", "", "Path to file containing the token for authenticating policy REST API requests (or env SAM_ADMIN_TOKEN)")
160163
rootCmd.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "Skip TLS verification for OIDC providers")
161164
rootCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)")

‎internal/controlplane/config.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type Options struct {
3535
InsecureSkipTLSVerify bool
3636
BiscuitTimeout time.Duration
3737
BiscuitTTL time.Duration // Lifespan minted into every issued Biscuit's expiration() fact; defaults to api.BiscuitTokenTTL
38+
OIDCSessionTTL time.Duration // How long an OIDC enrollment stays refreshable before the identity must re-authenticate interactively; defaults to api.OIDCSessionTTL
3839
AdminToken string // Optional: administrative bearer token for protecting policy and enrollment queue REST APIs
3940
AutoApproveEnrollment bool // If true, valid bootstrap token enrollment requests are immediately approved without administrative manual gate
4041
}
@@ -60,6 +61,9 @@ func (o *Options) Default() {
6061
if o.BiscuitTTL <= 0 {
6162
o.BiscuitTTL = api.BiscuitTokenTTL
6263
}
64+
if o.OIDCSessionTTL <= 0 {
65+
o.OIDCSessionTTL = api.OIDCSessionTTL
66+
}
6367
}
6468

6569
// Validate ensures options are valid.

‎internal/controlplane/server.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -552,21 +552,28 @@ func (s *Server) HandleRegister(w http.ResponseWriter, r *http.Request) {
552552
return
553553
}
554554

555+
// The session bounds how long refresh works without the identity proving
556+
// itself to the issuer again, so its length is the operator's re-auth
557+
// cadence decision (--oidc-session-ttl), not a constant.
558+
sessionExpiresAt := time.Now().Add(s.config.OIDCSessionTTL)
559+
555560
// Mint token. A biscuit must never outlive the OIDC token that vouched
556-
// for it, so its expiration is capped at whichever comes first.
561+
// for it, nor the session it belongs to; its expiration is capped at
562+
// whichever comes first.
557563
biscuitExpiry := time.Now().Add(s.config.BiscuitTTL)
558564
if token.Expiry.Before(biscuitExpiry) {
559565
biscuitExpiry = token.Expiry
560566
}
567+
if sessionExpiresAt.Before(biscuitExpiry) {
568+
biscuitExpiry = sessionExpiresAt
569+
}
561570
biscuitData, _, err := identity.MintBiscuitToken(privKey, claims, token, pID, biscuitExpiry, finalRoles, policyRoles, req.Labels)
562571
if err != nil {
563572
logger.Errorw("Biscuit minting failed", "peer_id", req.PeerId, "error", err)
564573
http.Error(w, "Failed to mint biscuit: "+err.Error(), http.StatusForbidden)
565574
return
566575
}
567576

568-
// Session TTL is 90 days for OIDC interactive enrollment
569-
sessionExpiresAt := time.Now().Add(api.OIDCSessionTTL)
570577
primaryRole := req.RequestedRole
571578

572579
claimsBytes, err := json.Marshal(claims)

‎internal/controlplane/server_test.go‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2207,6 +2207,100 @@ func TestAuthDenialPaths(t *testing.T) {
22072207
})
22082208
}
22092209

2210+
// TestOIDCSessionTTLIsConfigurable pins the GHSA-cgmg-9xf6-rrgw session-length
2211+
// answer: how long one OIDC login stays refreshable is the operator's
2212+
// --oidc-session-ttl decision. A lapsed session refuses refresh (forcing
2213+
// interactive re-auth) and the first biscuit never outlives the session.
2214+
func TestOIDCSessionTTLIsConfigurable(t *testing.T) {
2215+
issuer, mintToken := startCustomMockOIDC(t)
2216+
const sessionTTL = 2 * time.Second
2217+
srv, store, baseURL := setupTestServer(t, issuer, func(o *Options) {
2218+
o.OIDCSessionTTL = sessionTTL
2219+
})
2220+
defer func() {
2221+
_ = srv.Close()
2222+
_ = store.Close()
2223+
}()
2224+
2225+
ctx := context.Background()
2226+
client := &http.Client{Timeout: 5 * time.Second}
2227+
2228+
if err := store.SaveMeshPolicy(ctx, nil, []*api.PolicyBinding{
2229+
{Role: api.RoleNode, Members: []string{api.SystemAuthenticated}},
2230+
}); err != nil {
2231+
t.Fatal(err)
2232+
}
2233+
2234+
privNode, pubNode, err := crypto.GenerateKeyPair(crypto.Ed25519, -1)
2235+
if err != nil {
2236+
t.Fatal(err)
2237+
}
2238+
nodePeer, err := peer.IDFromPrivateKey(privNode)
2239+
if err != nil {
2240+
t.Fatal(err)
2241+
}
2242+
pubBytes, _ := crypto.MarshalPublicKey(pubNode)
2243+
enrolledAt := time.Now()
2244+
reqData, _ := proto.Marshal(&api.EnrollRequest{
2245+
Jwt: mintToken(map[string]interface{}{"sub": "short-session"}),
2246+
PeerId: nodePeer.String(),
2247+
PublicKey: pubBytes,
2248+
RequestedRole: api.RoleNode,
2249+
})
2250+
resp, err := client.Post(baseURL+"/register", "application/x-protobuf", bytes.NewReader(reqData))
2251+
if err != nil {
2252+
t.Fatalf("/register failed: %v", err)
2253+
}
2254+
body, _ := io.ReadAll(resp.Body)
2255+
_ = resp.Body.Close()
2256+
if resp.StatusCode != http.StatusOK {
2257+
t.Fatalf("/register status %s (body: %s)", resp.Status, body)
2258+
}
2259+
var enrollResp api.EnrollResponse
2260+
if err := proto.Unmarshal(body, &enrollResp); err != nil {
2261+
t.Fatal(err)
2262+
}
2263+
2264+
// The configured TTL, not the 90-day default, bounds the session record
2265+
// and the first biscuit alike.
2266+
nodeRecord, err := store.GetNode(ctx, nodePeer.String())
2267+
if err != nil {
2268+
t.Fatal(err)
2269+
}
2270+
if skew := nodeRecord.ExpiresAt.Sub(enrolledAt.Add(sessionTTL)); skew < -2*time.Second || skew > 2*time.Second {
2271+
t.Errorf("session ExpiresAt is %v, want ~%v", nodeRecord.ExpiresAt, enrolledAt.Add(sessionTTL))
2272+
}
2273+
if reported := time.Unix(enrollResp.Expiration, 0); reported.After(nodeRecord.ExpiresAt.Add(2 * time.Second)) {
2274+
t.Errorf("biscuit expiration %v outlives the session %v", reported, nodeRecord.ExpiresAt)
2275+
}
2276+
2277+
// Once the session lapses, a correctly signed refresh is refused: the
2278+
// identity has to go back to the OIDC provider.
2279+
time.Sleep(sessionTTL + 500*time.Millisecond)
2280+
2281+
timestamp := time.Now().UnixMilli()
2282+
challengeSig, err := privNode.Sign(api.RefreshChallenge(nodePeer.String(), timestamp))
2283+
if err != nil {
2284+
t.Fatal(err)
2285+
}
2286+
refreshData, _ := proto.Marshal(&api.TokenRefreshRequest{
2287+
ChallengeSignature: challengeSig,
2288+
Timestamp: timestamp,
2289+
})
2290+
req, _ := http.NewRequest("POST", baseURL+"/refresh", bytes.NewReader(refreshData))
2291+
req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(enrollResp.BiscuitToken))
2292+
req.Header.Set("Content-Type", "application/x-protobuf")
2293+
resp, err = client.Do(req)
2294+
if err != nil {
2295+
t.Fatalf("refresh failed: %v", err)
2296+
}
2297+
body, _ = io.ReadAll(resp.Body)
2298+
_ = resp.Body.Close()
2299+
if resp.StatusCode != http.StatusUnauthorized {
2300+
t.Fatalf("refresh after session expiry: got %s (body: %s), want 401", resp.Status, body)
2301+
}
2302+
}
2303+
22102304
// TestBanSurvivesKeypairRegeneration pins the fix for the GHSA-cgmg-9xf6-rrgw
22112305
// ban-bypass claim: bans were keyed only on peer_id, which the node itself
22122306
// mints, so a banned operator regenerated a keypair and re-enrolled. Revoking

‎site/content/docs/user/control-plane-configuration.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ The Control Plane is responsible for bridging user identities from trusted OIDC
2323
| `--allowed-audiences` | *None* | `sam-mesh-audience` | Comma-separated list of allowed JWT audiences. |
2424
| `--admin-token-path` (or env `SAM_ADMIN_TOKEN`) | *None* | *None* | File containing the secret token required in the HTTP Header `Authorization: Bearer <token>` for admin operations. |
2525
| `--insecure-skip-tls-verify` | *None* | `false` | Set to `true` to skip certificate validation for development/testing OIDC providers. |
26+
| `--biscuit-ttl` | *None* | `24h` | Lifespan minted into every issued Biscuit token. Capped to the OIDC token's own expiry when shorter. |
27+
| `--oidc-session-ttl` | *None* | `2160h` (90 days) | How long an OIDC enrollment stays refreshable before the identity must re-authenticate with the OIDC provider. Shorter values keep the provider authoritative for offboarding, at the cost of more frequent interactive re-enrollment. |
2628
| `--key-rotation-interval` | *None* | `24h` | Key rotation interval (e.g. `24h`). `0s` disables rotation. |
2729
| `--key-grace-period` | *None* | `1h` | Key grace period for rotated keys. |
2830
| `--lease-duration` | *None* | `15m` | Router lease registration TTL. |

0 commit comments

Comments
 (0)