Add token management helpers for API credentials
This commit is contained in:
@@ -34,8 +34,11 @@ var (
|
|||||||
ErrInvalidToken = errors.New("invalid api token")
|
ErrInvalidToken = errors.New("invalid api token")
|
||||||
ErrExpiredToken = errors.New("expired api token")
|
ErrExpiredToken = errors.New("expired api token")
|
||||||
ErrDisabledToken = errors.New("disabled 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 Effect string
|
||||||
type Operation string
|
type Operation string
|
||||||
type ResourceKind string
|
type ResourceKind string
|
||||||
@@ -192,6 +195,38 @@ func Entries(model vault.Model) ([]Token, error) {
|
|||||||
return out, nil
|
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) {
|
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
|
||||||
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
||||||
return Token{}, false, nil
|
return Token{}, false, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
@@ -115,6 +116,126 @@ func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error {
|
|||||||
return err
|
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) {
|
func (s *State) ShowSection(section Section) {
|
||||||
s.Section = section
|
s.Section = section
|
||||||
s.CurrentPath = nil
|
s.CurrentPath = nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"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) {
|
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user