Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/datalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,9 @@ var (
// TargetFactRules maps node and OIDC claims to target_fact datalog facts.
TargetFactRules []biscuit.Rule

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

// AllowIfTruePolicy is the static policy "allow if true" used during token verification.
Expand Down
3 changes: 3 additions & 0 deletions cmd/sam-control-plane/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ var (
keyRotationInterval time.Duration
keyGracePeriod time.Duration
leaseDuration time.Duration
biscuitTTL time.Duration
adminTokenPath string
insecureSkipTLSVerify bool
logLevel string
Expand Down Expand Up @@ -120,6 +121,7 @@ func main() {
KeyGracePeriod: keyGracePeriod,
InsecureSkipTLSVerify: insecureSkipTLSVerify,
BiscuitTimeout: 10 * time.Second,
BiscuitTTL: biscuitTTL,
AdminToken: adminToken,
AutoApproveEnrollment: autoApproveEnrollment,
}
Expand Down Expand Up @@ -153,6 +155,7 @@ func main() {
rootCmd.Flags().DurationVar(&keyRotationInterval, "key-rotation-interval", 24*time.Hour, "Key rotation interval (e.g. 24h). 0 disables rotation.")
rootCmd.Flags().DurationVar(&keyGracePeriod, "key-grace-period", 1*time.Hour, "Key grace period for rotated keys.")
rootCmd.Flags().DurationVar(&leaseDuration, "lease-duration", 15*time.Minute, "Router lease registration TTL.")
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.")
rootCmd.Flags().StringVar(&adminTokenPath, "admin-token-path", "", "Path to file containing the token for authenticating policy REST API requests (or env SAM_ADMIN_TOKEN)")
rootCmd.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "Skip TLS verification for OIDC providers")
rootCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)")
Expand Down
254 changes: 254 additions & 0 deletions internal/controlplane/biscuit_ttl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package controlplane

import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"fmt"
"io"
"net/http"
"testing"
"time"

"github.com/biscuit-auth/biscuit-go/v2"
"github.com/biscuit-auth/biscuit-go/v2/parser"
"github.com/google/sam/api"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
"google.golang.org/protobuf/proto"
)

// biscuitExpiration reads the expiration() authority fact out of a minted token.
func biscuitExpiration(t *testing.T, tokenBytes []byte, cpPubKey ed25519.PublicKey) time.Time {
t.Helper()

b, err := biscuit.Unmarshal(tokenBytes)
if err != nil {
t.Fatalf("malformed biscuit: %v", err)
}
authorizer, err := b.Authorizer(cpPubKey)
if err != nil {
t.Fatalf("authorizer: %v", err)
}
authorizer.AddPolicy(api.AllowIfTruePolicy)
if err := authorizer.Authorize(); err != nil {
t.Fatalf("authorize: %v", err)
}

rule, err := parser.FromStringRule(fmt.Sprintf(`get_exp($e) <- %s($e)`, api.FactExpiration))
if err != nil {
t.Fatal(err)
}
facts, err := authorizer.Query(rule)
if err != nil {
t.Fatalf("query: %v", err)
}
if len(facts) != 1 || len(facts[0].IDs) != 1 {
t.Fatalf("expected exactly one expiration fact, got %v", facts)
}
date, ok := facts[0].IDs[0].(biscuit.Date)
Comment on lines +61 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Similar to the issue in internal/identity/biscuit.go, biscuit.Fact has a named Predicate field. Accessing facts[0].IDs directly will cause a compilation error. Please access it via facts[0].Predicate.IDs.

Suggested change
if len(facts) != 1 || len(facts[0].IDs) != 1 {
t.Fatalf("expected exactly one expiration fact, got %v", facts)
}
date, ok := facts[0].IDs[0].(biscuit.Date)
if len(facts) != 1 || len(facts[0].Predicate.IDs) != 1 {
t.Fatalf("expected exactly one expiration fact, got %v", facts)
}
date, ok := facts[0].Predicate.IDs[0].(biscuit.Date)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

biscuit.Fact embeds Predicate anonymously, so fact.IDs is a promoted field selector and compiles fine.

biscuit-go/v2@v2.2.0/types.go:116:

type Fact struct {
	Predicate
}

Field promotion applies. go build ./... and go vet ./... are green on this branch.

Applying this suggestion would actually break the build here, because staticcheck is an active linter and QF1008 rejects the longer form. That rule is what made me write it this way in the first place:

internal/controlplane/biscuit_ttl_test.go:64:23: QF1008: could remove embedded field "Predicate" from selector (staticcheck)

QF1008 only fires on embedded fields, so the diagnostic is itself proof that the field is embedded. No change.

if !ok {
t.Fatalf("expiration term is %T, want biscuit.Date", facts[0].IDs[0])
}
return time.Time(date)
}

// registerNode enrolls a fresh peer over /register and returns its keys and the response.
func registerNode(t *testing.T, cpURL, jwtToken string) (crypto.PrivKey, peer.ID, *api.EnrollResponse) {
t.Helper()

priv, pub, err := crypto.GenerateKeyPair(crypto.Ed25519, -1)
if err != nil {
t.Fatal(err)
}
peerID, err := peer.IDFromPrivateKey(priv)
if err != nil {
t.Fatal(err)
}
pubBytes, err := crypto.MarshalPublicKey(pub)
if err != nil {
t.Fatal(err)
}

reqData, err := proto.Marshal(&api.EnrollRequest{
Jwt: jwtToken,
PeerId: peerID.String(),
PublicKey: pubBytes,
RequestedRole: api.RoleNode,
})
if err != nil {
t.Fatal(err)
}

resp, err := (&http.Client{Timeout: 5 * time.Second}).Post(cpURL+"/register", "application/x-protobuf", bytes.NewReader(reqData))
if err != nil {
t.Fatalf("/register failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("/register status %s: %s", resp.Status, string(body))
}

var enrollResp api.EnrollResponse
if err := proto.Unmarshal(body, &enrollResp); err != nil {
t.Fatalf("unmarshal EnrollResponse: %v", err)
}
return priv, peerID, &enrollResp
}

// refreshNode drives /refresh with a signed challenge and returns the new token.
func refreshNode(t *testing.T, cpURL string, priv crypto.PrivKey, currentBiscuit []byte) *api.TokenRefreshResponse {
t.Helper()

timestamp := time.Now().Unix()
sig, err := priv.Sign([]byte(fmt.Sprintf("%d", timestamp)))
if err != nil {
t.Fatal(err)
}
reqData, err := proto.Marshal(&api.TokenRefreshRequest{
Timestamp: timestamp,
ChallengeSignature: sig,
})
if err != nil {
t.Fatal(err)
}

req, err := http.NewRequest(http.MethodPost, cpURL+"/refresh", bytes.NewReader(reqData))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-protobuf")
req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(currentBiscuit))

resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
t.Fatalf("/refresh failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("/refresh status %s: %s", resp.Status, string(body))
}

var refreshResp api.TokenRefreshResponse
if err := proto.Unmarshal(body, &refreshResp); err != nil {
t.Fatalf("unmarshal TokenRefreshResponse: %v", err)
}
return &refreshResp
}

// assertNear fails unless got is within a second of want, absorbing the
// whole-second resolution of Biscuit date terms and the minting round trip.
func assertNear(t *testing.T, what string, got, want time.Time) {
t.Helper()
if skew := got.Sub(want); skew < -2*time.Second || skew > 2*time.Second {
t.Errorf("%s = %v, want ~%v (skew %v)", what, got.UTC(), want.UTC(), skew)
}
}

// TestBiscuitExpiryIsCappedByItsVoucher pins the rule that a biscuit never
// outlives whatever authorized it: the OIDC ID token on interactive enrollment,
// and the recorded OIDC session on refresh (where no live token is presented).
// The configured --biscuit-ttl is a ceiling, never a floor.
func TestBiscuitExpiryIsCappedByItsVoucher(t *testing.T) {
issuer, mintToken := startCustomMockOIDC(t)
srv, store, cpURL := setupTestServer(t, issuer)
defer func() {
_ = srv.Close()
_ = store.Close()
}()

ctx := context.Background()
if err := store.SaveMeshPolicy(ctx, []*api.PolicyRole{}, []*api.PolicyBinding{
{Role: api.RoleNode, Members: []string{"group:users"}},
}); err != nil {
t.Fatal(err)
}

newJWT := func(oidcTTL time.Duration) string {
return mintToken(map[string]interface{}{
"sub": "ttl-test",
"groups": []string{"users"},
"exp": time.Now().Add(oidcTTL).Unix(),
})
}

t.Run("register clamps to the OIDC token when it expires first", func(t *testing.T) {
srv.config.BiscuitTTL = 24 * time.Hour
oidcExpiry := time.Now().Add(10 * time.Minute)

_, _, resp := registerNode(t, cpURL, newJWT(10*time.Minute))
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)

assertNear(t, "biscuit expiration()", biscuitExpiration(t, resp.BiscuitToken, cpPubKey), oidcExpiry)
assertNear(t, "EnrollResponse.Expiration", time.Unix(resp.Expiration, 0), oidcExpiry)
})

t.Run("register uses the configured TTL when it expires first", func(t *testing.T) {
srv.config.BiscuitTTL = 5 * time.Minute
want := time.Now().Add(5 * time.Minute)

_, _, resp := registerNode(t, cpURL, newJWT(time.Hour))
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)

assertNear(t, "biscuit expiration()", biscuitExpiration(t, resp.BiscuitToken, cpPubKey), want)
assertNear(t, "EnrollResponse.Expiration", time.Unix(resp.Expiration, 0), want)
})

t.Run("refresh clamps to the end of the OIDC session", func(t *testing.T) {
srv.config.BiscuitTTL = 24 * time.Hour
priv, peerID, resp := registerNode(t, cpURL, newJWT(time.Hour))
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)

// Wind the 90-day session down to its last 10 minutes.
sessionEnd := time.Now().Add(10 * time.Minute)
record, err := store.GetNode(ctx, peerID.String())
if err != nil {
t.Fatal(err)
}
record.ExpiresAt = sessionEnd
if err := store.EnrollNode(ctx, record); err != nil {
t.Fatal(err)
}

refreshed := refreshNode(t, cpURL, priv, resp.BiscuitToken)
assertNear(t, "refreshed biscuit expiration()", biscuitExpiration(t, refreshed.BiscuitToken, cpPubKey), sessionEnd)
assertNear(t, "TokenRefreshResponse.ExpiresAt", time.Unix(refreshed.ExpiresAt, 0), sessionEnd)
})

t.Run("refresh uses the configured TTL when the session never expires", func(t *testing.T) {
srv.config.BiscuitTTL = 30 * time.Minute
priv, peerID, resp := registerNode(t, cpURL, newJWT(time.Hour))
cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey)

// Bootstrap-style record: no session deadline at all.
record, err := store.GetNode(ctx, peerID.String())
if err != nil {
t.Fatal(err)
}
record.ExpiresAt = time.Time{}
if err := store.EnrollNode(ctx, record); err != nil {
t.Fatal(err)
}

want := time.Now().Add(30 * time.Minute)
refreshed := refreshNode(t, cpURL, priv, resp.BiscuitToken)
assertNear(t, "refreshed biscuit expiration()", biscuitExpiration(t, refreshed.BiscuitToken, cpPubKey), want)
})
}
10 changes: 8 additions & 2 deletions internal/controlplane/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ package controlplane
import (
"fmt"
"time"

"github.com/google/sam/api"
)

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

// Default sets default values for control plane options.
Expand All @@ -54,6 +57,9 @@ func (o *Options) Default() {
if o.KeyGracePeriod <= 0 {
o.KeyGracePeriod = 1 * time.Hour
}
if o.BiscuitTTL <= 0 {
o.BiscuitTTL = api.BiscuitTokenTTL
}
}

// Validate ensures options are valid.
Expand Down
Loading
Loading