diff --git a/apitokens/tokens.go b/apitokens/tokens.go index 39cc6bb..267ed52 100644 --- a/apitokens/tokens.go +++ b/apitokens/tokens.go @@ -34,8 +34,11 @@ var ( ErrInvalidToken = errors.New("invalid api token") ErrExpiredToken = errors.New("expired api token") ErrDisabledToken = errors.New("disabled api token") + ErrTokenNotFound = errors.New("api token not found") ) +var EntryPath = []string{"Root", "API Tokens"} + type Effect string type Operation string type ResourceKind string @@ -192,6 +195,38 @@ func Entries(model vault.Model) ([]Token, error) { return out, nil } +func Find(model vault.Model, id string) (Token, error) { + tokens, err := Entries(model) + if err != nil { + return Token{}, err + } + for _, token := range tokens { + if token.ID == id { + return token, nil + } + } + return Token{}, ErrTokenNotFound +} + +func Upsert(model *vault.Model, token Token) { + model.UpsertEntry(token.Entry(EntryPath)) + model.CreateGroup([]string{"Root"}, "API Tokens") +} + +func Delete(model *vault.Model, id string) error { + for i, entry := range model.Entries { + token, ok, err := TokenFromEntry(entry) + if err != nil { + return err + } + if ok && token.ID == id { + model.Entries = append(model.Entries[:i], model.Entries[i+1:]...) + return nil + } + } + return ErrTokenNotFound +} + func TokenFromEntry(entry vault.Entry) (Token, bool, error) { if entry.Fields[FieldType] != EntryTypeAPIToken { return Token{}, false, nil diff --git a/apitokens/tokens_test.go b/apitokens/tokens_test.go index e2c732f..c00615c 100644 --- a/apitokens/tokens_test.go +++ b/apitokens/tokens_test.go @@ -72,6 +72,60 @@ func TestEntriesFiltersOnlyAPITokens(t *testing.T) { } } +func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) { + t.Parallel() + + model := vault.Model{} + token := Token{ + ID: "token-1", + Name: "CLI", + ClientName: "grpc-cli", + SecretHash: "hash-1", + CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), + } + + Upsert(&model, token) + if len(model.Entries) != 1 { + t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries)) + } + if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) { + t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got) + } + + token.ClientName = "rotated-client" + token.Disabled = true + Upsert(&model, token) + + got, err := Find(model, "token-1") + if err != nil { + t.Fatalf("Find() error = %v", err) + } + if got.ClientName != "rotated-client" || !got.Disabled { + t.Fatalf("Find() = %#v, want updated client name and disabled state", got) + } +} + +func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath), + {ID: "entry-1", Title: "Regular Entry"}, + }, + } + + if err := Delete(&model, "token-1"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" { + t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries) + } + if err := Delete(&model, "missing"); err != ErrTokenNotFound { + t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound) + } +} + func TestIssueRotateDisableAndRevokeToken(t *testing.T) { t.Parallel() diff --git a/appstate/state.go b/appstate/state.go index 963fab9..c3383f9 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" "strings" + "time" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apitokens" @@ -115,6 +116,126 @@ func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error { return err } +func (s *State) APITokens() ([]apitokens.Token, error) { + model, err := s.currentModel() + if err != nil { + return nil, err + } + return apitokens.Entries(model) +} + +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() + 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) + s.Dirty = true + return token, 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() + 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) + s.Dirty = true + return token, 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) + s.Dirty = true + return nil +} + +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 + } + apitokens.Upsert(&model, apitokens.Disable(token)) + session.Replace(model) + s.Dirty = true + return nil +} + +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 + } + apitokens.Upsert(&model, apitokens.Revoke(token, when)) + session.Replace(model) + s.Dirty = true + return nil +} + +func (s *State) DeleteAPIToken(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 + } + if err := apitokens.Delete(&model, id); err != nil { + return err + } + session.Replace(model) + s.Dirty = true + return nil +} + func (s *State) ShowSection(section Section) { s.Section = section s.CurrentPath = nil diff --git a/appstate/state_test.go b/appstate/state_test.go index c115063..3d22623 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -4,6 +4,7 @@ import ( "errors" "slices" "testing" + "time" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apitokens" @@ -73,6 +74,65 @@ func TestResolveApprovalDelegatesToManager(t *testing.T) { } } +func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) { + t.Parallel() + + session := &mutableStubSession{model: vault.Model{}} + state := State{Session: session} + now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) + expiresAt := now.Add(24 * time.Hour) + + issued, secret, err := state.IssueAPIToken("CLI", "grpc-cli", &expiresAt, now) + if err != nil { + t.Fatalf("IssueAPIToken() error = %v", err) + } + if issued.ID == "" || secret == "" { + t.Fatalf("IssueAPIToken() = %#v, %q, want non-empty id and secret", issued, secret) + } + + tokens, err := state.APITokens() + if err != nil { + t.Fatalf("APITokens() error = %v", err) + } + if len(tokens) != 1 || tokens[0].ID != issued.ID { + t.Fatalf("APITokens() = %#v, want issued token", tokens) + } + + rotated, rotatedSecret, err := state.RotateAPIToken(issued.ID, now.Add(time.Hour)) + if err != nil { + t.Fatalf("RotateAPIToken() error = %v", err) + } + if rotated.ID != issued.ID || rotatedSecret == "" || rotatedSecret == secret { + t.Fatalf("RotateAPIToken() = %#v, %q, want same id and new secret", rotated, rotatedSecret) + } + + if err := state.DisableAPIToken(issued.ID); err != nil { + t.Fatalf("DisableAPIToken() error = %v", err) + } + if err := state.RevokeAPIToken(issued.ID, now.Add(2*time.Hour)); err != nil { + t.Fatalf("RevokeAPIToken() error = %v", err) + } + + tokens, err = state.APITokens() + if err != nil { + t.Fatalf("APITokens() after revoke error = %v", err) + } + if len(tokens) != 1 || !tokens[0].Disabled || tokens[0].RevokedAt == nil { + t.Fatalf("APITokens() after revoke = %#v, want disabled revoked token", tokens) + } + + if err := state.DeleteAPIToken(issued.ID); err != nil { + t.Fatalf("DeleteAPIToken() error = %v", err) + } + tokens, err = state.APITokens() + if err != nil { + t.Fatalf("APITokens() after delete error = %v", err) + } + if len(tokens) != 0 { + t.Fatalf("APITokens() after delete = %#v, want empty", tokens) + } +} + func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { t.Parallel()