Skip to content

Commit c3ad7bf

Browse files
authored
Merge pull request #316 from aojea/bug296
Fix missing expiration checks
2 parents 6502323 + 61d2fc7 commit c3ad7bf

14 files changed

Lines changed: 858 additions & 38 deletions

File tree

api/datalog.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ var (
253253
// TargetFactRules maps node and OIDC claims to target_fact datalog facts.
254254
TargetFactRules []biscuit.Rule
255255

256-
// ControlPlaneStaticTimeCheck is the standard check for verifying OIDC token expiration.
256+
// ControlPlaneStaticTimeCheck is the standard check for verifying a Biscuit's
257+
// own expiration() fact. Every path that admits a token must add it together
258+
// with a FactTime fact; see identity.EnforceExpiration.
257259
ControlPlaneStaticTimeCheck biscuit.Check
258260

259261
// AllowIfTruePolicy is the static policy "allow if true" used during token verification.

cmd/sam-control-plane/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ var (
4141
keyRotationInterval time.Duration
4242
keyGracePeriod time.Duration
4343
leaseDuration time.Duration
44+
biscuitTTL time.Duration
4445
adminTokenPath string
4546
insecureSkipTLSVerify bool
4647
logLevel string
@@ -120,6 +121,7 @@ func main() {
120121
KeyGracePeriod: keyGracePeriod,
121122
InsecureSkipTLSVerify: insecureSkipTLSVerify,
122123
BiscuitTimeout: 10 * time.Second,
124+
BiscuitTTL: biscuitTTL,
123125
AdminToken: adminToken,
124126
AutoApproveEnrollment: autoApproveEnrollment,
125127
}
@@ -153,6 +155,7 @@ func main() {
153155
rootCmd.Flags().DurationVar(&keyRotationInterval, "key-rotation-interval", 24*time.Hour, "Key rotation interval (e.g. 24h). 0 disables rotation.")
154156
rootCmd.Flags().DurationVar(&keyGracePeriod, "key-grace-period", 1*time.Hour, "Key grace period for rotated keys.")
155157
rootCmd.Flags().DurationVar(&leaseDuration, "lease-duration", 15*time.Minute, "Router lease registration TTL.")
158+
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.")
156159
rootCmd.Flags().StringVar(&adminTokenPath, "admin-token-path", "", "Path to file containing the token for authenticating policy REST API requests (or env SAM_ADMIN_TOKEN)")
157160
rootCmd.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "Skip TLS verification for OIDC providers")
158161
rootCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)")
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package controlplane
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"crypto/ed25519"
21+
"encoding/base64"
22+
"fmt"
23+
"io"
24+
"net/http"
25+
"testing"
26+
"time"
27+
28+
"github.com/biscuit-auth/biscuit-go/v2"
29+
"github.com/biscuit-auth/biscuit-go/v2/parser"
30+
"github.com/google/sam/api"
31+
"github.com/libp2p/go-libp2p/core/crypto"
32+
"github.com/libp2p/go-libp2p/core/peer"
33+
"google.golang.org/protobuf/proto"
34+
)
35+
36+
// biscuitExpiration reads the expiration() authority fact out of a minted token.
37+
func biscuitExpiration(t *testing.T, tokenBytes []byte, cpPubKey ed25519.PublicKey) time.Time {
38+
t.Helper()
39+
40+
b, err := biscuit.Unmarshal(tokenBytes)
41+
if err != nil {
42+
t.Fatalf("malformed biscuit: %v", err)
43+
}
44+
authorizer, err := b.Authorizer(cpPubKey)
45+
if err != nil {
46+
t.Fatalf("authorizer: %v", err)
47+
}
48+
authorizer.AddPolicy(api.AllowIfTruePolicy)
49+
if err := authorizer.Authorize(); err != nil {
50+
t.Fatalf("authorize: %v", err)
51+
}
52+
53+
rule, err := parser.FromStringRule(fmt.Sprintf(`get_exp($e) <- %s($e)`, api.FactExpiration))
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
facts, err := authorizer.Query(rule)
58+
if err != nil {
59+
t.Fatalf("query: %v", err)
60+
}
61+
if len(facts) != 1 || len(facts[0].IDs) != 1 {
62+
t.Fatalf("expected exactly one expiration fact, got %v", facts)
63+
}
64+
date, ok := facts[0].IDs[0].(biscuit.Date)
65+
if !ok {
66+
t.Fatalf("expiration term is %T, want biscuit.Date", facts[0].IDs[0])
67+
}
68+
return time.Time(date)
69+
}
70+
71+
// registerNode enrolls a fresh peer over /register and returns its keys and the response.
72+
func registerNode(t *testing.T, cpURL, jwtToken string) (crypto.PrivKey, peer.ID, *api.EnrollResponse) {
73+
t.Helper()
74+
75+
priv, pub, err := crypto.GenerateKeyPair(crypto.Ed25519, -1)
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
peerID, err := peer.IDFromPrivateKey(priv)
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
pubBytes, err := crypto.MarshalPublicKey(pub)
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
88+
reqData, err := proto.Marshal(&api.EnrollRequest{
89+
Jwt: jwtToken,
90+
PeerId: peerID.String(),
91+
PublicKey: pubBytes,
92+
RequestedRole: api.RoleNode,
93+
})
94+
if err != nil {
95+
t.Fatal(err)
96+
}
97+
98+
resp, err := (&http.Client{Timeout: 5 * time.Second}).Post(cpURL+"/register", "application/x-protobuf", bytes.NewReader(reqData))
99+
if err != nil {
100+
t.Fatalf("/register failed: %v", err)
101+
}
102+
defer func() { _ = resp.Body.Close() }()
103+
body, _ := io.ReadAll(resp.Body)
104+
if resp.StatusCode != http.StatusOK {
105+
t.Fatalf("/register status %s: %s", resp.Status, string(body))
106+
}
107+
108+
var enrollResp api.EnrollResponse
109+
if err := proto.Unmarshal(body, &enrollResp); err != nil {
110+
t.Fatalf("unmarshal EnrollResponse: %v", err)
111+
}
112+
return priv, peerID, &enrollResp
113+
}
114+
115+
// refreshNode drives /refresh with a signed challenge and returns the new token.
116+
func refreshNode(t *testing.T, cpURL string, priv crypto.PrivKey, currentBiscuit []byte) *api.TokenRefreshResponse {
117+
t.Helper()
118+
119+
timestamp := time.Now().Unix()
120+
sig, err := priv.Sign([]byte(fmt.Sprintf("%d", timestamp)))
121+
if err != nil {
122+
t.Fatal(err)
123+
}
124+
reqData, err := proto.Marshal(&api.TokenRefreshRequest{
125+
Timestamp: timestamp,
126+
ChallengeSignature: sig,
127+
})
128+
if err != nil {
129+
t.Fatal(err)
130+
}
131+
132+
req, err := http.NewRequest(http.MethodPost, cpURL+"/refresh", bytes.NewReader(reqData))
133+
if err != nil {
134+
t.Fatal(err)
135+
}
136+
req.Header.Set("Content-Type", "application/x-protobuf")
137+
req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(currentBiscuit))
138+
139+
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
140+
if err != nil {
141+
t.Fatalf("/refresh failed: %v", err)
142+
}
143+
defer func() { _ = resp.Body.Close() }()
144+
body, _ := io.ReadAll(resp.Body)
145+
if resp.StatusCode != http.StatusOK {
146+
t.Fatalf("/refresh status %s: %s", resp.Status, string(body))
147+
}
148+
149+
var refreshResp api.TokenRefreshResponse
150+
if err := proto.Unmarshal(body, &refreshResp); err != nil {
151+
t.Fatalf("unmarshal TokenRefreshResponse: %v", err)
152+
}
153+
return &refreshResp
154+
}
155+
156+
// assertNear fails unless got is within a second of want, absorbing the
157+
// whole-second resolution of Biscuit date terms and the minting round trip.
158+
func assertNear(t *testing.T, what string, got, want time.Time) {
159+
t.Helper()
160+
if skew := got.Sub(want); skew < -2*time.Second || skew > 2*time.Second {
161+
t.Errorf("%s = %v, want ~%v (skew %v)", what, got.UTC(), want.UTC(), skew)
162+
}
163+
}
164+
165+
// TestBiscuitExpiryIsCappedByItsVoucher pins the rule that a biscuit never
166+
// outlives whatever authorized it: the OIDC ID token on interactive enrollment,
167+
// and the recorded OIDC session on refresh (where no live token is presented).
168+
// The configured --biscuit-ttl is a ceiling, never a floor.
169+
func TestBiscuitExpiryIsCappedByItsVoucher(t *testing.T) {
170+
issuer, mintToken := startCustomMockOIDC(t)
171+
srv, store, cpURL := setupTestServer(t, issuer)
172+
defer func() {
173+
_ = srv.Close()
174+
_ = store.Close()
175+
}()
176+
177+
ctx := context.Background()
178+
if err := store.SaveMeshPolicy(ctx, []*api.PolicyRole{}, []*api.PolicyBinding{
179+
{Role: api.RoleNode, Members: []string{"group:users"}},
180+
}); err != nil {
181+
t.Fatal(err)
182+
}
183+
184+
newJWT := func(oidcTTL time.Duration) string {
185+
return mintToken(map[string]interface{}{
186+
"sub": "ttl-test",
187+
"groups": []string{"users"},
188+
"exp": time.Now().Add(oidcTTL).Unix(),
189+
})
190+
}
191+
192+
t.Run("register clamps to the OIDC token when it expires first", func(t *testing.T) {
193+
srv.config.BiscuitTTL = 24 * time.Hour
194+
oidcExpiry := time.Now().Add(10 * time.Minute)
195+
196+
_, _, resp := registerNode(t, cpURL, newJWT(10*time.Minute))
197+
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)
198+
199+
assertNear(t, "biscuit expiration()", biscuitExpiration(t, resp.BiscuitToken, cpPubKey), oidcExpiry)
200+
assertNear(t, "EnrollResponse.Expiration", time.Unix(resp.Expiration, 0), oidcExpiry)
201+
})
202+
203+
t.Run("register uses the configured TTL when it expires first", func(t *testing.T) {
204+
srv.config.BiscuitTTL = 5 * time.Minute
205+
want := time.Now().Add(5 * time.Minute)
206+
207+
_, _, resp := registerNode(t, cpURL, newJWT(time.Hour))
208+
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)
209+
210+
assertNear(t, "biscuit expiration()", biscuitExpiration(t, resp.BiscuitToken, cpPubKey), want)
211+
assertNear(t, "EnrollResponse.Expiration", time.Unix(resp.Expiration, 0), want)
212+
})
213+
214+
t.Run("refresh clamps to the end of the OIDC session", func(t *testing.T) {
215+
srv.config.BiscuitTTL = 24 * time.Hour
216+
priv, peerID, resp := registerNode(t, cpURL, newJWT(time.Hour))
217+
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)
218+
219+
// Wind the 90-day session down to its last 10 minutes.
220+
sessionEnd := time.Now().Add(10 * time.Minute)
221+
record, err := store.GetNode(ctx, peerID.String())
222+
if err != nil {
223+
t.Fatal(err)
224+
}
225+
record.ExpiresAt = sessionEnd
226+
if err := store.EnrollNode(ctx, record); err != nil {
227+
t.Fatal(err)
228+
}
229+
230+
refreshed := refreshNode(t, cpURL, priv, resp.BiscuitToken)
231+
assertNear(t, "refreshed biscuit expiration()", biscuitExpiration(t, refreshed.BiscuitToken, cpPubKey), sessionEnd)
232+
assertNear(t, "TokenRefreshResponse.ExpiresAt", time.Unix(refreshed.ExpiresAt, 0), sessionEnd)
233+
})
234+
235+
t.Run("refresh uses the configured TTL when the session never expires", func(t *testing.T) {
236+
srv.config.BiscuitTTL = 30 * time.Minute
237+
priv, peerID, resp := registerNode(t, cpURL, newJWT(time.Hour))
238+
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)
239+
240+
// Bootstrap-style record: no session deadline at all.
241+
record, err := store.GetNode(ctx, peerID.String())
242+
if err != nil {
243+
t.Fatal(err)
244+
}
245+
record.ExpiresAt = time.Time{}
246+
if err := store.EnrollNode(ctx, record); err != nil {
247+
t.Fatal(err)
248+
}
249+
250+
want := time.Now().Add(30 * time.Minute)
251+
refreshed := refreshNode(t, cpURL, priv, resp.BiscuitToken)
252+
assertNear(t, "refreshed biscuit expiration()", biscuitExpiration(t, refreshed.BiscuitToken, cpPubKey), want)
253+
})
254+
}

internal/controlplane/config.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ package controlplane
1717
import (
1818
"fmt"
1919
"time"
20+
21+
"github.com/google/sam/api"
2022
)
2123

2224
// Options holds configuration for the control plane.
@@ -32,8 +34,9 @@ type Options struct {
3234
KeyGracePeriod time.Duration
3335
InsecureSkipTLSVerify bool
3436
BiscuitTimeout time.Duration
35-
AdminToken string // Optional: administrative bearer token for protecting policy and enrollment queue REST APIs
36-
AutoApproveEnrollment bool // If true, valid bootstrap token enrollment requests are immediately approved without administrative manual gate
37+
BiscuitTTL time.Duration // Lifespan minted into every issued Biscuit's expiration() fact; defaults to api.BiscuitTokenTTL
38+
AdminToken string // Optional: administrative bearer token for protecting policy and enrollment queue REST APIs
39+
AutoApproveEnrollment bool // If true, valid bootstrap token enrollment requests are immediately approved without administrative manual gate
3740
}
3841

3942
// Default sets default values for control plane options.
@@ -54,6 +57,9 @@ func (o *Options) Default() {
5457
if o.KeyGracePeriod <= 0 {
5558
o.KeyGracePeriod = 1 * time.Hour
5659
}
60+
if o.BiscuitTTL <= 0 {
61+
o.BiscuitTTL = api.BiscuitTokenTTL
62+
}
5763
}
5864

5965
// Validate ensures options are valid.

0 commit comments

Comments
 (0)