Skip to content

Commit d32ba1b

Browse files
case-zbzclaude
andauthored
feat(auth): add service-level auth with sctx integration (#8)
* feat(auth): add service-level auth with sctx integration Add mesh-level authentication infrastructure: - Metadata type for sctx.Context[M] shared across all mesh services - Keychain interface with FileKeychain for loading signing keys - Admin helpers for bootstrapping sctx.Admin[Metadata] - MeshAuth gRPC service (ExchangeToken, RevokeToken) built into every node - Guard interceptors (unary + stream) validating tokens per-RPC method - Node/NodeBuilder/Server wiring for auth, guards, and MeshAuth Closes #7 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(auth): address review findings — nodeToken, revocation auth, interface contract - Generate node's own token during Build() via self-assertion exchange, making guard interceptors functional - Enforce self-revocation in RevokeToken — caller can only revoke own token - Add LoadTrustedCAs to Keychain interface, remove FileKeychain type assertion - Fail hard in interceptor when CallerFromContext fails after guard pass - Sanitize id with filepath.Base in FileKeychain to prevent path traversal - Preserve existing GuardRegistry in Build() instead of unconditional overwrite - Capture base policy once in DefaultMeshPolicy (avoid double invocation) - Add //go:build testing tags to keychain_test.go, interceptor_test.go, metadata_test.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(auth): add automatic token exchange in ServiceClientPool ServiceClientPool now performs transparent token exchange when the node has auth configured. On first connection to a target node, the pool creates a signed assertion, calls MeshAuth.ExchangeToken, and attaches the token to all outgoing calls via grpc.PerRPCCredentials. Callers using ServiceClient.Get(ctx) get a fully authenticated client with no manual token management. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CI lint findings — gosec nolint, errcheck, variable shadow - Add nolint:gosec to tokenMetadataKey (metadata key, not credential) - Add nolint:gosec to FileKeychain os.ReadFile calls (paths sanitized) - Fix errcheck on tmpConn.Close() in client.go - Fix variable shadow in ExchangeToken (err -> unmarshalErr) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 50c9151 commit d32ba1b

18 files changed

Lines changed: 1645 additions & 2 deletions

admin.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package aegis
2+
3+
import (
4+
"context"
5+
"crypto"
6+
"crypto/x509"
7+
"fmt"
8+
"time"
9+
10+
"github.com/zoobz-io/sctx"
11+
)
12+
13+
// NewAdmin creates an sctx.Admin[Metadata] from a private key and trusted CA pool.
14+
func NewAdmin(privateKey crypto.PrivateKey, trustedCAs *x509.CertPool) (sctx.Admin[Metadata], error) {
15+
admin, err := sctx.NewAdminService[Metadata](privateKey, trustedCAs)
16+
if err != nil {
17+
return nil, fmt.Errorf("failed to create admin: %w", err)
18+
}
19+
20+
if err := admin.SetPolicy(DefaultMeshPolicy()); err != nil {
21+
return nil, fmt.Errorf("failed to set policy: %w", err)
22+
}
23+
24+
cache := sctx.NewBoundedMemoryCache[Metadata](sctx.CacheOptions{
25+
MaxSize: 1000,
26+
CleanupInterval: 5 * time.Minute,
27+
})
28+
if err := admin.SetCache(cache); err != nil {
29+
return nil, fmt.Errorf("failed to set cache: %w", err)
30+
}
31+
32+
return admin, nil
33+
}
34+
35+
// NewAdminFromKeychain creates an Admin by loading keys from a Keychain.
36+
func NewAdminFromKeychain(ctx context.Context, keychain Keychain, id string) (sctx.Admin[Metadata], error) {
37+
key, err := keychain.LoadPrivateKey(ctx, id)
38+
if err != nil {
39+
return nil, fmt.Errorf("failed to load private key: %w", err)
40+
}
41+
42+
caPool, err := keychain.LoadTrustedCAs(ctx)
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to load CA pool: %w", err)
45+
}
46+
47+
return NewAdmin(key, caPool)
48+
}
49+
50+
// DefaultMeshPolicy returns a ContextPolicy that populates Metadata from certificate fields.
51+
// CN → NodeID, O (first) → ServiceName.
52+
func DefaultMeshPolicy() sctx.ContextPolicy[Metadata] {
53+
basePolicy := sctx.DefaultContextPolicy[Metadata]()
54+
return func(cert *x509.Certificate) (*sctx.Context[Metadata], error) {
55+
base, err := basePolicy(cert)
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
base.Metadata = Metadata{
61+
NodeID: cert.Subject.CommonName,
62+
ServiceName: firstOrEmpty(cert.Subject.Organization),
63+
}
64+
65+
return base, nil
66+
}
67+
}
68+
69+
func firstOrEmpty(s []string) string {
70+
if len(s) > 0 {
71+
return s[0]
72+
}
73+
return ""
74+
}

admin_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//go:build testing
2+
3+
package aegis
4+
5+
import (
6+
"context"
7+
"crypto/ed25519"
8+
"crypto/rand"
9+
"crypto/x509"
10+
"crypto/x509/pkix"
11+
"math/big"
12+
"testing"
13+
"time"
14+
15+
"github.com/zoobz-io/sctx"
16+
)
17+
18+
func TestNewAdmin(t *testing.T) {
19+
sctx.ResetAdminForTesting()
20+
21+
priv, caPool := generateTestAdminDeps(t)
22+
23+
admin, err := NewAdmin(priv, caPool)
24+
if err != nil {
25+
t.Fatalf("unexpected error: %v", err)
26+
}
27+
if admin == nil {
28+
t.Fatal("expected non-nil admin")
29+
}
30+
}
31+
32+
func TestNewAdminFromKeychain(t *testing.T) {
33+
sctx.ResetAdminForTesting()
34+
35+
dir := t.TempDir()
36+
writeTestKey(t, dir, "node-1")
37+
writeTestCACert(t, dir)
38+
39+
kc := NewFileKeychain(dir)
40+
admin, err := NewAdminFromKeychain(context.Background(), kc, "node-1")
41+
if err != nil {
42+
t.Fatalf("unexpected error: %v", err)
43+
}
44+
if admin == nil {
45+
t.Fatal("expected non-nil admin")
46+
}
47+
}
48+
49+
func TestDefaultMeshPolicy(t *testing.T) {
50+
policy := DefaultMeshPolicy()
51+
52+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
53+
if err != nil {
54+
t.Fatalf("failed to generate key: %v", err)
55+
}
56+
_ = priv
57+
58+
template := &x509.Certificate{
59+
SerialNumber: big.NewInt(1),
60+
Subject: pkix.Name{
61+
CommonName: "node-1",
62+
Organization: []string{"api-service"},
63+
},
64+
NotBefore: time.Now().Add(-time.Hour),
65+
NotAfter: time.Now().Add(24 * time.Hour),
66+
}
67+
68+
certDER, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv)
69+
if err != nil {
70+
t.Fatalf("failed to create cert: %v", err)
71+
}
72+
73+
cert, err := x509.ParseCertificate(certDER)
74+
if err != nil {
75+
t.Fatalf("failed to parse cert: %v", err)
76+
}
77+
78+
sc, err := policy(cert)
79+
if err != nil {
80+
t.Fatalf("policy returned error: %v", err)
81+
}
82+
83+
if sc.Metadata.NodeID != "node-1" {
84+
t.Errorf("expected NodeID 'node-1', got %q", sc.Metadata.NodeID)
85+
}
86+
if sc.Metadata.ServiceName != "api-service" {
87+
t.Errorf("expected ServiceName 'api-service', got %q", sc.Metadata.ServiceName)
88+
}
89+
}
90+
91+
func generateTestAdminDeps(t *testing.T) (ed25519.PrivateKey, *x509.CertPool) {
92+
t.Helper()
93+
94+
_, priv, err := ed25519.GenerateKey(rand.Reader)
95+
if err != nil {
96+
t.Fatalf("failed to generate key: %v", err)
97+
}
98+
99+
caPub, caPriv, err := ed25519.GenerateKey(rand.Reader)
100+
if err != nil {
101+
t.Fatalf("failed to generate CA key: %v", err)
102+
}
103+
104+
caTemplate := &x509.Certificate{
105+
SerialNumber: big.NewInt(1),
106+
Subject: pkix.Name{CommonName: "test-ca"},
107+
NotBefore: time.Now().Add(-time.Hour),
108+
NotAfter: time.Now().Add(24 * time.Hour),
109+
IsCA: true,
110+
BasicConstraintsValid: true,
111+
KeyUsage: x509.KeyUsageCertSign,
112+
}
113+
114+
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caPub, caPriv)
115+
if err != nil {
116+
t.Fatalf("failed to create CA cert: %v", err)
117+
}
118+
119+
caCert, err := x509.ParseCertificate(caCertDER)
120+
if err != nil {
121+
t.Fatalf("failed to parse CA cert: %v", err)
122+
}
123+
124+
pool := x509.NewCertPool()
125+
pool.AddCert(caCert)
126+
127+
return priv, pool
128+
}

client.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package aegis
22

33
import (
44
"context"
5+
"crypto/x509"
6+
"encoding/json"
57
"errors"
8+
"fmt"
69
"sync"
710
"sync/atomic"
11+
"time"
812

13+
"github.com/zoobz-io/sctx"
914
"google.golang.org/grpc"
1015
"google.golang.org/grpc/credentials"
1116
)
@@ -59,7 +64,20 @@ func (p *ServiceClientPool) getOrCreateConn(ctx context.Context, address string)
5964
}
6065

6166
creds := credentials.NewTLS(p.node.TLSConfig.GetClientTLSConfig(address))
62-
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(creds))
67+
opts := []grpc.DialOption{grpc.WithTransportCredentials(creds)}
68+
69+
// If the node has auth, exchange a token with the target and attach to outgoing calls
70+
if p.node.Admin != nil && p.node.TLSConfig != nil {
71+
tokenCreds, err := p.exchangeToken(ctx, address, creds)
72+
if err != nil {
73+
return nil, fmt.Errorf("token exchange with %s failed: %w", address, err)
74+
}
75+
if tokenCreds != nil {
76+
opts = append(opts, grpc.WithPerRPCCredentials(tokenCreds))
77+
}
78+
}
79+
80+
conn, err := grpc.NewClient(address, opts...)
6381
if err != nil {
6482
return nil, err
6583
}
@@ -154,3 +172,59 @@ func (sc *ServiceClient[T]) Get(ctx context.Context) (T, error) {
154172
}
155173
return sc.newClient(conn), nil
156174
}
175+
176+
// exchangeToken creates a temporary connection to the target, calls MeshAuth.ExchangeToken,
177+
// and returns PerRPCCredentials that attach the token to outgoing calls.
178+
func (p *ServiceClientPool) exchangeToken(ctx context.Context, address string, transportCreds credentials.TransportCredentials) (*tokenCredentials, error) {
179+
// Create a temporary connection for the token exchange
180+
tmpConn, err := grpc.NewClient(address, grpc.WithTransportCredentials(transportCreds))
181+
if err != nil {
182+
return nil, fmt.Errorf("failed to connect for token exchange: %w", err)
183+
}
184+
defer func() { _ = tmpConn.Close() }()
185+
186+
// Get the node's TLS cert and private key for the assertion
187+
cert, err := x509.ParseCertificate(p.node.TLSConfig.Certificate.Certificate[0])
188+
if err != nil {
189+
return nil, fmt.Errorf("failed to parse node certificate: %w", err)
190+
}
191+
192+
assertion, err := sctx.CreateAssertion(p.node.TLSConfig.Certificate.PrivateKey, cert)
193+
if err != nil {
194+
return nil, fmt.Errorf("failed to create assertion: %w", err)
195+
}
196+
197+
assertionBytes, err := json.Marshal(assertion)
198+
if err != nil {
199+
return nil, fmt.Errorf("failed to marshal assertion: %w", err)
200+
}
201+
202+
client := NewMeshAuthClient(tmpConn)
203+
resp, err := client.ExchangeToken(ctx, &TokenExchangeRequest{Assertion: assertionBytes})
204+
if err != nil {
205+
return nil, fmt.Errorf("token exchange RPC failed: %w", err)
206+
}
207+
208+
return &tokenCredentials{
209+
token: resp.Token,
210+
expiresAt: time.Unix(resp.ExpiresAt, 0),
211+
}, nil
212+
}
213+
214+
// tokenCredentials implements grpc.PerRPCCredentials to attach the aegis token
215+
// to outgoing gRPC metadata on every call.
216+
type tokenCredentials struct {
217+
token string
218+
expiresAt time.Time
219+
}
220+
221+
func (tc *tokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
222+
if time.Now().After(tc.expiresAt) {
223+
return nil, errors.New("aegis token expired")
224+
}
225+
return map[string]string{tokenMetadataKey: tc.token}, nil
226+
}
227+
228+
func (tc *tokenCredentials) RequireTransportSecurity() bool {
229+
return true
230+
}

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
module github.com/zoobz-io/aegis
22

3-
go 1.24
3+
go 1.24.0
44

55
toolchain go1.25.5
66

77
require (
8+
github.com/zoobz-io/sctx v1.0.3
89
google.golang.org/grpc v1.74.2
910
google.golang.org/protobuf v1.36.6
1011
)
1112

1213
require (
14+
github.com/zoobz-io/capitan v1.0.2 // indirect
1315
golang.org/x/net v0.40.0 // indirect
1416
golang.org/x/sys v0.33.0 // indirect
1517
golang.org/x/text v0.25.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
88
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
99
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
1010
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
11+
github.com/zoobz-io/capitan v1.0.2 h1:/NKAgntWeRIN4S0oaZJNk6qaYuDN/Gw68+81kLbkLrc=
12+
github.com/zoobz-io/capitan v1.0.2/go.mod h1:tFqS6q99gRvgYBzvFIfJMDV1ukWU61H6ZB4HyZmiWuI=
13+
github.com/zoobz-io/sctx v1.0.3 h1:dkv5F0PWDnjVOPpe+Vr0PwQ5uQrb/zcENSwv4FTR3rw=
14+
github.com/zoobz-io/sctx v1.0.3/go.mod h1:YH+5vk3FJ+u398Z11rtKlbHW5p9Wl5Bt5WfcvMeZfoQ=
1115
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
1216
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
1317
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=

0 commit comments

Comments
 (0)