Compare commits

...

8 Commits

Author SHA1 Message Date
joejulian 3b323ea4fd Merge pull request 'Complete browser extension gRPC flow' (#4) from feature/browser-extension-grpc into main
ci / lint-test (push) Successful in 3m49s
ci / build (push) Successful in 6m7s
Reviewed-on: #4
2026-04-12 18:58:28 +00:00
Joe Julian 8117e3e8c1 Move browser bridge gRPC address to flag 2026-04-12 07:38:23 -07:00
Joe Julian 77e92a2368 Simplify browser extension manifest descriptions 2026-04-12 07:03:06 -07:00
Joe Julian 4b8c1de1a6 Remove extension gRPC address setting 2026-04-12 06:59:59 -07:00
Joe Julian af2ce66b78 Remove completed browser extension todo items 2026-04-12 06:52:23 -07:00
Joe Julian a02d4a3b1c Unify browser bridge request normalization 2026-04-12 06:48:12 -07:00
Joe Julian 57870ca4f1 Clean up browser bridge and mutation helpers 2026-04-12 00:02:50 -07:00
Joe Julian dc7dd19543 Fix browser authorization edge cases 2026-04-11 23:56:48 -07:00
19 changed files with 439 additions and 281 deletions
-9
View File
@@ -46,15 +46,6 @@ feeling like the same application rather than three related UIs.
These should remain in the main user flow rather than being hidden behind a settings gear.
- Browser extension:
make browser autofill page-driven rather than popup-driven so the user does not need to manually open the extension to discover or use matches.
- Browser extension:
add per-page or per-tab match detection and visible state so the browser can indicate when KeePassGO has a candidate for the current login form.
- Browser extension:
persist page match state so opening the extension popup is not required multiple times for the same page and does not repeat the whole discovery workflow unnecessarily.
- Browser extension:
show inline field affordances on detected username and password inputs, with a candidate chooser anchored to the field, so browser autofill is selectable from the form itself like Android autofill.
- Browser extension:
treat missing visibility of a pending authorization request during browser autofill as a workflow failure and fix approval surfacing before expanding extension scope further.
- Local open flow:
make the start screen primarily about opening a vault, not configuring one.
- Local open flow:
+1 -1
View File
@@ -8,7 +8,7 @@ The Arch package installs this directory under `/usr/share/keepassgo/browser-ext
- `manifest.chromium.json` is the Chromium/Chrome manifest template
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
- `options.html` stores the local gRPC address and API token in browser extension storage
- `options.html` stores the API token in browser extension storage
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
+1 -7
View File
@@ -3,7 +3,6 @@ const nativeHost = "com.keepassgo.browser";
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
const defaultSettings = {
grpcAddress: "",
bearerToken: ""
};
const pageStatePrefix = "keepassgo-page-state:";
@@ -174,9 +173,8 @@ function connectNative(message) {
}
async function loadSettings() {
const stored = await storageGet(["grpcAddress", "bearerToken"]);
const stored = await storageGet(["bearerToken"]);
return {
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
};
}
@@ -418,7 +416,6 @@ async function fetchStatus(settings) {
}
const status = await connectNative({
action: "status",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken
});
return {
@@ -515,7 +512,6 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
const matches = await connectNative({
action: "find-logins",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken,
url: resolvedURL
});
@@ -601,7 +597,6 @@ async function fillLogin(tabId, entryId) {
const response = await connectNative({
action: "get-login",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
@@ -697,7 +692,6 @@ if (isNodeTestEnv) {
return;
case "keepassgo-save-settings":
await storageSet({
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
bearerToken: String(message.settings?.bearerToken || "").trim()
});
await refreshActivePage({ force: true }).catch(() => null);
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO over the local gRPC API.",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"],
"background": {
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO over the local gRPC API.",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": [
"activeTab",
"nativeMessaging",
+1 -5
View File
@@ -11,14 +11,10 @@
<header class="topbar">
<div>
<h1>Browser Settings</h1>
<p class="subtle">Configure how the extension reaches KeePassGO.</p>
<p class="subtle">Connect the extension to KeePassGO.</p>
</div>
</header>
<form id="settings-form" class="settings-form">
<label>
<span>gRPC address</span>
<input id="grpc-address" name="grpc-address" type="text" value="" placeholder="Leave blank for the local default socket" autocomplete="off">
</label>
<label>
<span>API token</span>
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
-2
View File
@@ -22,7 +22,6 @@ async function loadSettings() {
if (!response?.success) {
throw new Error(response?.error || "Could not load settings.");
}
document.getElementById("grpc-address").value = response.settings.grpcAddress || "";
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
}
@@ -34,7 +33,6 @@ async function saveSettings(event) {
const response = await runtimeSend({
type: "keepassgo-save-settings",
settings: {
grpcAddress: document.getElementById("grpc-address").value,
bearerToken: document.getElementById("bearer-token").value
}
});
+51 -18
View File
@@ -11,9 +11,17 @@ import (
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"google.golang.org/grpc"
)
type bridgeConfig struct {
grpcAddr string
}
func main() {
cfg := bridgeConfig{
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
}
if len(os.Args) > 1 {
switch strings.TrimSpace(os.Args[1]) {
case "install-native-host":
@@ -22,13 +30,13 @@ func main() {
}
return
case "status":
if err := runStatus(os.Args[2:]); err != nil {
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
fail(err)
}
return
}
}
if err := runNativeMessage(); err != nil {
if err := runNativeMessage(cfg); err != nil {
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
os.Exit(1)
}
@@ -79,54 +87,79 @@ func runInstallNativeHost(args []string) error {
return nil
}
func runStatus(args []string) error {
func runStatus(cfg bridgeConfig, args []string) error {
fs := flag.NewFlagSet("status", flag.ContinueOnError)
grpcAddr := fs.String("grpc-addr", grpcaddr.Default(runtime.GOOS), "KeePassGO local gRPC address")
token := fs.String("token", "", "KeePassGO API bearer token")
if err := fs.Parse(args); err != nil {
return err
}
req := browserbridge.Request{
Action: "status",
GRPCAddress: strings.TrimSpace(*grpcAddr),
BearerToken: strings.TrimSpace(*token),
}
connCfg, err := req.Connection()
if err != nil {
return err
}
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
resp := browserbridge.HandleRequest(ctx, req, client)
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func runNativeMessage() error {
func runNativeMessage(cfg bridgeConfig) error {
req, err := browserbridge.ReadRequest(os.Stdin)
if err != nil {
return err
}
connCfg, err := req.Connection()
if err != nil {
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
}
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
}
defer func() { _ = conn.Close() }()
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, client))
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
}
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
}
func defaultBinaryPath() (string, error) {
return browserbridge.ResolveBridgeBinaryPath("")
}
func resolveGlobalGRPCAddr(args []string) string {
addr := grpcaddr.Default(runtime.GOOS)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--grpc-addr="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
}
}
return addr
}
func stripGlobalGRPCAddrFlags(args []string) []string {
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
i++
continue
case strings.HasPrefix(arg, "--grpc-addr="):
continue
default:
out = append(out, args[i])
}
}
return out
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+3 -4
View File
@@ -11,7 +11,7 @@ The browser extension does **not** talk to vault files directly.
## Security Model
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
- The browser extension stores the gRPC address and API token in browser extension storage.
- The browser extension stores the API token in browser extension storage.
- The native messaging host receives the token on each request from the extension.
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
- The native messaging host does not persist the token to disk.
@@ -78,14 +78,13 @@ Firefox:
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
2. Open the extension settings page.
3. Leave the gRPC address blank to use the local default Unix socket, or set an explicit address if you overrode the listener.
4. Paste an API token scoped for browser login lookup and credential copy.
3. Paste an API token scoped for browser login lookup and credential copy.
Chromium / Chrome:
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
3. Configure the gRPC address and API token in the extension settings page.
3. Configure the API token in the extension settings page.
## Current Browser Flow
+3
View File
@@ -101,6 +101,9 @@ func (h *Host) Stop() error {
h.started = false
h.grpcServer.Stop()
err := h.listener.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
if h.socketPath != "" {
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
err = removeErr
+71 -68
View File
@@ -113,9 +113,6 @@ func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSession
if err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
pendingApprovals := s.approvals.Pending()
var tokenPending uint32
for _, pending := range pendingApprovals {
@@ -123,11 +120,16 @@ func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSession
tokenPending++
}
}
s.mu.RLock()
locked := s.locked
dirty := s.dirty
entryCount := uint32(len(s.model.Entries))
s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
Locked: locked,
Dirty: dirty,
EntryCount: entryCount,
PendingApprovalCount: uint32(len(pendingApprovals)),
TokenPendingApprovalCount: tokenPending,
}, nil
@@ -348,22 +350,26 @@ func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.T
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
return nil, err
}
return browserMatchesWithinPath(matches, match.resource.Path), nil
return s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path)
}
return out, nil
}
func browserMatchesWithinPath(matches []rankedBrowserMatch, path []string) []*keepassgov1.BrowserLoginMatch {
func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) {
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches {
if len(path) > len(match.resource.Path) {
continue
}
if slices.Equal(path, match.resource.Path[:len(path)]) {
out = append(out, match.match)
if !slices.Equal(path, match.resource.Path[:len(path)]) {
continue
}
if match.decision == apitokens.DecisionDeny {
continue
}
out = append(out, match.match)
}
return out
return out, nil
}
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
@@ -482,76 +488,75 @@ func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequ
}, nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
func (s *Server) mutateAuthorizedVisiblePath(ctx context.Context, clientPath []string, op apitokens.Operation, mutate func(*vault.Model, []string) error) error {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
return status.Error(codes.FailedPrecondition, "vault is locked")
}
parentPath := expandClientPath(visibleModel(model), req.GetParentPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, parentPath); err != nil {
return nil, err
internalPath := expandClientPath(visibleModel(model), clientPath)
if _, err := s.authorizePathRequest(ctx, op, internalPath); err != nil {
return err
}
return s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
return mutate(model, internalPath)
})
}
func (s *Server) mutateAuthorizedModel(authorize func() error, mutate func(*vault.Model) error) error {
if err := authorize(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.model.CreateGroup(parentPath, req.GetName())
if err := mutate(&s.model); err != nil {
return err
}
s.dirty = true
s.syncMutationLocked()
return nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetParentPath(), apitokens.OperationMutateGroup, func(model *vault.Model, parentPath []string) error {
model.CreateGroup(parentPath, req.GetName())
return nil
}); err != nil {
return nil, err
}
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
groupPath := expandClientPath(visibleModel(model), req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
if err := model.RenameGroup(groupPath, req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return status.Error(codes.NotFound, err.Error())
}
return status.Errorf(codes.Internal, "rename group: %v", err)
}
return nil
}); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.RenameGroup(groupPath, req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
groupPath := expandClientPath(visibleModel(model), req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
if err := model.DeleteGroup(groupPath); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return status.Error(codes.FailedPrecondition, err.Error())
default:
return status.Errorf(codes.Internal, "delete group: %v", err)
}
}
return nil
}); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.DeleteGroup(groupPath); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return nil, status.Error(codes.FailedPrecondition, err.Error())
default:
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
}
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteGroupResponse{}, nil
}
@@ -568,12 +573,12 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil {
return nil, err
}
s.mu.Lock()
s.model.UpsertEntry(entry)
s.dirty = true
s.syncMutationLocked()
s.mu.Unlock()
if err := s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
model.UpsertEntry(entry)
return nil
}); err != nil {
return nil, err
}
return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil
}
@@ -1068,8 +1073,6 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
return "exact-host", 3
case strings.HasSuffix(pageHost, "."+entryHost):
return "subdomain", 2
case strings.HasSuffix(entryHost, "."+pageHost):
return "parent-domain", 1
default:
return "", 0
}
+102
View File
@@ -336,6 +336,108 @@ func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T)
}
}
func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "rusty-casino",
Title: "Rusty Casino",
Username: "rustyryan",
Password: "bellagio-1",
URL: "https://vault.heist.example.invalid",
Path: []string{"Crews", "Bellagio"},
},
{
ID: "benedict-vault",
Title: "Benedict Vault",
Username: "terrybenedict",
Password: "bellagio-2",
URL: "https://vault.heist.example.invalid",
Path: []string{"Crews", "Bellagio", "Denied"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews", "Bellagio", "Denied"}}},
),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.FindBrowserLoginsResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://vault.heist.example.invalid/login",
})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if got := pending.Resource.Path; !slices.Equal(got, []string{"Crews", "Bellagio"}) {
t.Fatalf("pending.Resource.Path = %v, want [Crews Bellagio]", got)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow once) error = %v", err)
}
resp := <-respCh
if err := <-errCh; err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if got := resp.Matches[0].Id; got != "rusty-casino" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want rusty-casino", got)
}
}
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "inside-man-accounts",
Title: "Inside Man Accounts",
Username: "daltonrussell",
Password: "diamond-1",
URL: "https://accounts.heist.example.invalid",
Path: []string{"Crews", "Bank"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://heist.example.invalid/login",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 0 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 0", len(resp.Matches))
}
_, err = client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "inside-man-accounts",
PageUrl: "https://heist.example.invalid/login",
})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.InvalidArgument)
}
}
func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
t.Parallel()
+6 -5
View File
@@ -13,10 +13,11 @@ import (
)
var (
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
)
type Outcome string
@@ -120,7 +121,7 @@ func (b *Broker) SetChangeNotifier(notify func()) {
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil {
return Result{}, ErrRequestTimedOut
return Result{}, ErrBrokerNotConfigured
}
pending := &pendingRequest{
+10
View File
@@ -121,6 +121,16 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
}
}
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
t.Parallel()
var broker *Broker
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrBrokerNotConfigured) {
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
}
}
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
t.Parallel()
+73 -101
View File
@@ -192,139 +192,111 @@ func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
}
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return apitokens.Token{}, "", err
}
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
return token, secret, nil
return result.token, result.secret, nil
}
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil {
return apitokens.Token{}, "", err
}
token, err := apitokens.Find(model, id)
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return apitokens.Token{}, "", err
}
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
return token, secret, nil
return result.token, result.secret, nil
}
func (s *State) UpsertAPIToken(token apitokens.Token) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
apitokens.Upsert(&model, token)
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
return nil
_, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) DisableAPIToken(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
token = apitokens.Disable(token)
apitokens.Upsert(&model, token)
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
return nil
_, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token = apitokens.Disable(token)
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) RevokeAPIToken(id string, when time.Time) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
token = apitokens.Revoke(token, when)
apitokens.Upsert(&model, token)
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
return nil
_, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token = apitokens.Revoke(token, when)
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) DeleteAPIToken(id string) error {
_, err := s.mutateAPITokens(apiaudit.EventTokenDeleted, "deleted API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
if err := apitokens.Delete(model, id); err != nil {
return tokenMutationResult{}, err
}
return tokenMutationResult{token: token}, nil
})
return err
}
type tokenMutationResult struct {
token apitokens.Token
secret string
}
func (s *State) mutateAPITokens(eventType apiaudit.EventType, message string, mutate func(*vault.Model) (tokenMutationResult, error)) (tokenMutationResult, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
return tokenMutationResult{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
return tokenMutationResult{}, err
}
token, err := apitokens.Find(model, id)
result, err := mutate(&model)
if err != nil {
return err
}
if err := apitokens.Delete(&model, id); err != nil {
return err
return tokenMutationResult{}, err
}
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return err
return tokenMutationResult{}, err
}
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
return nil
s.recordTokenAudit(eventType, result.token, message)
return result, nil
}
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
+3 -1
View File
@@ -109,7 +109,9 @@ func ensureBrowserNativeHosts() {
if err != nil {
return
}
_ = browserbridge.EnsureNativeHostManifests(appBinaryPath)
if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
}
}
type uiApprovalManager struct {
+42 -28
View File
@@ -15,6 +15,8 @@ import (
"git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
const (
@@ -22,11 +24,11 @@ const (
defaultFirefoxID = "browser@keepassgo.com"
maxNativeMessageSize = 1024 * 1024
chromiumIDBytes = 16
responseVersion = "1"
)
type Request struct {
Action string `json:"action"`
GRPCAddress string `json:"grpcAddress,omitempty"`
BearerToken string `json:"bearerToken,omitempty"`
URL string `json:"url,omitempty"`
EntryID string `json:"entryId,omitempty"`
@@ -136,22 +138,27 @@ func WriteResponse(w io.Writer, resp Response) error {
return err
}
func (r Request) Connection() (Connection, error) {
conn := Connection{
GRPCAddress: strings.TrimSpace(r.GRPCAddress),
func (r Request) Connection(grpcAddr string) (Connection, error) {
return normalizeConnection(Connection{
GRPCAddress: strings.TrimSpace(grpcAddr),
BearerToken: strings.TrimSpace(r.BearerToken),
}
if conn.GRPCAddress == "" {
})
}
func normalizeConnection(conn Connection) (Connection, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" {
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
}
if conn.BearerToken == "" {
if strings.TrimSpace(conn.BearerToken) == "" {
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
}
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
return conn, nil
}
func HandleRequest(ctx context.Context, req Request, client Client) Response {
conn, err := req.Connection()
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
conn, err := req.Connection(grpcAddr)
if err != nil {
return Response{Success: false, Error: err.Error()}
}
@@ -162,33 +169,25 @@ func HandleRequest(ctx context.Context, req Request, client Client) Response {
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: status, Version: "1"}
return Response{Success: true, Status: status, Version: responseVersion}
case "find-logins":
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
if status.Locked {
return Response{Success: true, Status: status, Matches: nil, Version: "1"}
}
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: true, Status: status, Matches: matches, Version: "1"}
case "get-login":
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
if status.Locked {
return Response{Success: false, Error: "vault is locked", Status: status}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
case "get-login":
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: status}
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
default:
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
}
@@ -198,6 +197,21 @@ func disconnectedStatus(addr string) *Status {
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
}
func availableStatus(addr string) *Status {
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
}
func inferredActionStatus(addr string, err error) *Status {
switch gstatus.Code(err) {
case gcodes.FailedPrecondition:
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
case gcodes.OK:
return availableStatus(addr)
default:
return nil
}
}
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
resp, err := client.Status(ctx)
if err != nil {
+56 -21
View File
@@ -13,6 +13,8 @@ import (
"testing"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
func TestReadRequestAndWriteResponse(t *testing.T) {
@@ -21,7 +23,6 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
var input bytes.Buffer
body, err := json.Marshal(Request{
Action: "find-logins",
GRPCAddress: "127.0.0.1:47777",
BearerToken: "secret",
URL: "https://example.invalid/login",
})
@@ -42,8 +43,8 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
if req.Action != "find-logins" || req.BearerToken != "secret" {
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
}
if conn, err := req.Connection(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
t.Fatalf("req.Connection() = (%#v, %v), want explicit tcp address preserved", conn, err)
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
}
var output bytes.Buffer
@@ -70,8 +71,7 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
func TestHandleRequestFindLogins(t *testing.T) {
t.Parallel()
client := fakeClient{
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
client := &fakeClient{
matches: []*keepassgov1.BrowserLoginMatch{
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
},
@@ -80,19 +80,22 @@ func TestHandleRequestFindLogins(t *testing.T) {
Action: "find-logins",
BearerToken: "secret",
URL: "https://vault.example.invalid/login",
}, client)
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
}
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
t.Parallel()
client := fakeClient{
client := &fakeClient{
status: &keepassgov1.GetSessionStatusResponse{
Locked: false,
EntryCount: 2,
@@ -103,7 +106,7 @@ func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
resp := HandleRequest(context.Background(), Request{
Action: "status",
BearerToken: "secret",
}, client)
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
}
@@ -121,8 +124,7 @@ func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
func TestHandleRequestGetLogin(t *testing.T) {
t.Parallel()
client := fakeClient{
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
client := &fakeClient{
credential: &keepassgov1.GetBrowserCredentialResponse{
Id: "vault-console",
Username: "dannyocean",
@@ -135,19 +137,42 @@ func TestHandleRequestGetLogin(t *testing.T) {
BearerToken: "secret",
EntryID: "vault-console",
URL: "https://vault.example.invalid/login",
}, client)
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
}
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
t.Parallel()
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
resp := HandleRequest(context.Background(), Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
}
if resp.Status == nil || !resp.Status.Locked {
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestRequiresBearerToken(t *testing.T) {
t.Parallel()
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{})
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
if resp.Success {
t.Fatal("HandleRequest().Success = true, want false without token")
}
@@ -157,9 +182,9 @@ func TestRequestConnectionDefaultsAddress(t *testing.T) {
t.Parallel()
req := Request{Action: "status", BearerToken: "secret"}
conn, err := req.Connection()
conn, err := req.Connection("")
if err != nil {
t.Fatalf("Connection() error = %v", err)
t.Fatalf("Connection(\"\") error = %v", err)
}
if conn.GRPCAddress == "" {
t.Fatal("Connection().GRPCAddress = empty, want default address")
@@ -282,10 +307,13 @@ func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testin
}
type fakeClient struct {
status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch
credential *keepassgov1.GetBrowserCredentialResponse
err error
status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch
credential *keepassgov1.GetBrowserCredentialResponse
err error
matchesErr error
credentialErr error
statusCalls int
}
func writeExtensionManifest(t *testing.T, path, name string) {
@@ -333,7 +361,8 @@ func assertManifestContainsExtension(t *testing.T, path, field, want string) {
}
}
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
func (f *fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
f.statusCalls++
if f.err != nil {
return nil, f.err
}
@@ -343,14 +372,20 @@ func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusRespon
return f.status, nil
}
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
if f.matchesErr != nil {
return nil, f.matchesErr
}
if f.err != nil {
return nil, f.err
}
return f.matches, nil
}
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
if f.credentialErr != nil {
return nil, f.credentialErr
}
if f.err != nil {
return nil, f.err
}
+14 -9
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net"
"runtime"
"strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
@@ -18,14 +17,20 @@ type GRPCClient struct {
client keepassgov1.VaultServiceClient
}
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
conn, err := req.Connection(grpcAddr)
if err != nil {
return nil, nil, nil, err
}
return Dial(ctx, conn)
}
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" {
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
normalized, err := normalizeConnection(conn)
if err != nil {
return nil, nil, nil, err
}
if strings.TrimSpace(conn.BearerToken) == "" {
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
}
network, endpoint, err := grpcaddr.Parse(conn.GRPCAddress)
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
if err != nil {
return nil, nil, nil, err
}
@@ -40,9 +45,9 @@ func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient,
}),
)
if err != nil {
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", strings.TrimSpace(conn.GRPCAddress), err)
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
}
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken))
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
}