From e5f12276fb291b72a33a8a78588341a2ad553792 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 19 Oct 2026 04:31:17 +0530 Subject: [PATCH] feat: report the credential's expiry in authn.Identity Lets the server close a NATS subscriber's connection when its token or JWT expires. Refs priompt/priompt#47. Co-Authored-By: Claude Opus 5.5 --- authn/authn.go | 15 +++++++++------ authn/jwks_test.go | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/authn/authn.go b/authn/authn.go index 183697b..2d7efdc 100644 --- a/authn/authn.go +++ b/authn/authn.go @@ -33,11 +33,14 @@ type identityKey struct{} // Identity is who a request acts as, established by a Provider. Org "" is // admin (all orgs); Write gates mutating RPCs; Sub is the principal for audit -// trails ("" for static tokens, the JWT sub for priompt-auth tokens). +// trails ("" for static tokens, the JWT sub for priompt-auth tokens). Expires is +// when the credential lapses (zero = never), so long-lived connections that +// authenticate once, such as NATS subscribers, can be closed at that point. type Identity struct { - Sub string - Org string - Write bool + Sub string + Org string + Write bool + Expires time.Time } // Provider authenticates one kind of bearer credential. It receives the raw @@ -98,7 +101,7 @@ func (p StaticProvider) Authenticate(_ context.Context, header string) (Identity if !tok.Expires.IsZero() && time.Now().After(tok.Expires) { return Identity{}, status.Error(codes.Unauthenticated, "token expired") } - return Identity{Org: tok.Org, Write: tok.Write}, nil + return Identity{Org: tok.Org, Write: tok.Write, Expires: tok.Expires}, nil } // JWKSProvider authenticates JWT-shaped credentials against a priompt-auth @@ -123,7 +126,7 @@ func (p JWKSProvider) Authenticate(_ context.Context, header string) (Identity, if err != nil { return Identity{}, ErrUnrecognized } - return Identity{Sub: c.Sub, Org: c.Org, Write: c.RW}, nil + return Identity{Sub: c.Sub, Org: c.Org, Write: c.RW, Expires: time.Unix(c.Exp, 0)}, nil } // Interceptor authenticates every RPC through the provider chain and attaches diff --git a/authn/jwks_test.go b/authn/jwks_test.go index 9ce9535..c3c15e2 100644 --- a/authn/jwks_test.go +++ b/authn/jwks_test.go @@ -1,6 +1,7 @@ package authn import ( + "context" "crypto/ed25519" "encoding/base64" "encoding/json" @@ -106,3 +107,24 @@ func TestInterceptorJWT(t *testing.T) { t.Error("malformed JWT must be rejected") } } + +// TestIdentityExpires: both providers report when the credential lapses, so a +// connection that authenticated once (a NATS subscriber) can be closed then. +func TestIdentityExpires(t *testing.T) { + ctx := context.Background() + exp := time.Now().Add(time.Hour).Truncate(time.Second) + id, err := NewStatic(map[string]Token{"t1": {Org: "acme", Expires: exp}, "t2": {}}).Authenticate(ctx, "Bearer t1") + if err != nil || !id.Expires.Equal(exp) { + t.Fatalf("static: Expires = %v err=%v, want %v", id.Expires, err, exp) + } + if id, _ := NewStatic(map[string]Token{"t2": {}}).Authenticate(ctx, "Bearer t2"); !id.Expires.IsZero() { + t.Fatalf("static without expiry: Expires = %v, want zero", id.Expires) + } + + pub, priv, _ := ed25519.GenerateKey(nil) + p := JWKSProvider{Keys: NewKeySet(jwksServer(t, "k1", pub).URL)} + id, err = p.Authenticate(ctx, "Bearer "+MintJWT(priv, "k1", Claims{Org: "acme", Exp: exp.Unix()})) + if err != nil || !id.Expires.Equal(exp) { + t.Fatalf("jwt: Expires = %v err=%v, want %v", id.Expires, err, exp) + } +}