Authorize gRPC requests with vault API tokens

This commit is contained in:
Joe Julian
2026-03-29 22:56:18 -07:00
parent 47942014d7
commit dba0bf1f2c
3 changed files with 321 additions and 67 deletions
+136 -33
View File
@@ -3,10 +3,14 @@ package api
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"net"
"os"
"testing"
"time"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
@@ -32,6 +36,66 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
}
}
func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
t.Parallel()
expiredAt := time.Date(2026, 3, 29, 11, 59, 0, 0, time.UTC)
token, _, err := apitokens.Issue("Expired", "grpc-test", &expiredAt, time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
token.Entry([]string{"Root", "API Tokens"}),
},
})
defer cleanup()
oldNow := timeNow
timeNow = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
defer func() { timeNow = oldNow }()
_, err = client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("ListEntries() code = %v, want %v for expired token", status.Code(err), codes.Unauthenticated)
}
}
func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
),
},
})
defer cleanup()
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
t.Parallel()
@@ -52,7 +116,7 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
@@ -108,7 +172,7 @@ func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: lifecycle.unlockPassword,
@@ -182,7 +246,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: "correct horse battery staple",
@@ -228,7 +292,7 @@ func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
testCases := []struct {
name string
@@ -350,7 +414,7 @@ func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
err := tt.call(client, ctx)
if status.Code(err) != tt.want {
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
@@ -365,7 +429,7 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
@@ -389,7 +453,7 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
@@ -436,7 +500,7 @@ func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
ParentPath: []string{"Root"},
Name: "Finance",
@@ -472,7 +536,7 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
if err != nil {
t.Fatalf("GeneratePassword() error = %v", err)
@@ -489,7 +553,7 @@ func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
_, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument)
@@ -502,7 +566,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
client, clipboardWriter, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "git-server",
Target: "password",
@@ -521,7 +585,7 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "ha-codex",
@@ -565,7 +629,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
@@ -604,7 +668,7 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
@@ -659,7 +723,7 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
Template: &keepassgov1.Entry{
Id: "website-login",
@@ -712,7 +776,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err)
@@ -739,7 +803,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
@@ -785,14 +849,31 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
}
const defaultTestTokenSecret = "test-token"
func tokenContext(secret string) context.Context {
return metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer "+secret)
}
func hashSecretForTest(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry {
t.Helper()
token, _, err := apitokens.Issue("Test Client", "grpc-test", nil, time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
token.Policies = append([]apitokens.PolicyRule(nil), rules...)
return token.Entry([]string{"Root", "API Tokens"})
}
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServer(
vault.Model{
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
@@ -823,6 +904,17 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
),
},
Templates: []vault.Entry{
{
@@ -839,10 +931,18 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Path: []string{"Templates"},
},
},
},
passwords.DefaultProfiles(),
clipboardWriter,
))
}
return newTestClientForModel(t, model)
}
func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
clipboardWriter := &memoryClipboardWriter{}
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service)
go func() {
_ = server.Serve(listener)
@@ -870,14 +970,17 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
lifecycle.model,
passwords.DefaultProfiles(),
clipboardWriter,
lifecycle,
model := lifecycle.model
model.Entries = append(model.Entries, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
))
lifecycle.model = model
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service)
go func() {
_ = server.Serve(listener)