refactor(credstore): embed credential store and drop sdk-go dependency

- Embed the minimal credstore subset used by tea (SecureStore,
  EncryptedFileStore, KeyringStore, FileStore) as modules/credstore so
  external SDK renames can no longer break the build
- Keep the on-disk format fully compatible: AES-256-GCM values with the
  v1: prefix, credentials.json / credentials.json.enc paths, and the
  Token JSON field names are unchanged, verified by a ciphertext fixture
  generated with sdk-go v1.1.0
- Store the keyring master key under a tea-owned account name
- Reuse the existing kernel-level filelock module instead of the
  upstream lockfile protocol, removing a stale-lock race
- Cover roundtrip, keyring-unavailable fallback, and fixture decryption
  with tests using a mocked keyring
- Remove github.com/go-signet/sdk-go and promote
  github.com/zalando/go-keyring to a direct dependency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bo-Yi Wu
2026-08-03 21:59:07 +08:00
parent f34697c5ed
commit f6d939a8df
15 changed files with 1453 additions and 5 deletions
+1 -2
View File
@@ -12,13 +12,13 @@ require (
github.com/adrg/xdg v0.5.3 github.com/adrg/xdg v0.5.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/enescakir/emoji v1.0.0 github.com/enescakir/emoji v1.0.0
github.com/go-signet/sdk-go v1.1.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/olekukonko/tablewriter v1.1.4 github.com/olekukonko/tablewriter v1.1.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli-docs/v3 v3.1.0
github.com/urfave/cli/v3 v3.10.1 github.com/urfave/cli/v3 v3.10.1
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
@@ -74,7 +74,6 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.56.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
-2
View File
@@ -89,8 +89,6 @@ github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/go-signet/sdk-go v1.1.0 h1:wHKg9P+goQ14A1Q0gtC6m3mCzRFWwL1peAGz/zhmAZQ=
github.com/go-signet/sdk-go v1.1.0/go.mod h1:bmi7nDAu7o6MQnUE3K7ZNEKU4xqh3u/SMbPC5GanOR8=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+2 -1
View File
@@ -8,8 +8,9 @@ import (
"sync" "sync"
"time" "time"
"gitea.dev/tea/modules/credstore"
"github.com/adrg/xdg" "github.com/adrg/xdg"
"github.com/go-signet/sdk-go/credstore"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"encoding/json"
"fmt"
)
// Codec handles encoding/decoding values to/from strings for storage.
type Codec[T any] interface {
Encode(v T) (string, error)
Decode(s string) (T, error)
}
// JSONCodec encodes T as JSON.
type JSONCodec[T any] struct{}
// Encode marshals v to a JSON string.
func (JSONCodec[T]) Encode(v T) (string, error) {
data, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("failed to marshal data: %w", err)
}
return string(data), nil
}
// Decode unmarshals a JSON string into T.
func (JSONCodec[T]) Decode(s string) (T, error) {
var v T
if err := json.Unmarshal([]byte(s), &v); err != nil {
return v, fmt.Errorf("failed to unmarshal data: %w", err)
}
return v, nil
}
// StringCodec is the identity codec for plain strings.
type StringCodec struct{}
// Encode returns the string as-is.
func (StringCodec) Encode(v string) (string, error) {
return v, nil
}
// Decode returns the string as-is.
func (StringCodec) Decode(s string) (string, error) {
return s, nil
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package credstore provides secure storage for OAuth tokens. Values are
// AES-256-GCM-encrypted into a JSON file while only the 32-byte master key
// lives in the OS keyring; when the keyring is unavailable the store falls
// back to plaintext file storage.
package credstore
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"sync"
)
// masterKeySize is the AES-256 key length in bytes.
const masterKeySize = 32
// masterKeyUser is the keyring account name under which the master key is
// stored. It must never change: installations hold their master key under
// this exact name.
const masterKeyUser = "__tea_master_key__"
// sealedPrefix versions the on-disk encrypted value format so a future
// algorithm change can be detected instead of guessed at.
const sealedPrefix = "v1:"
// masterKey manages a per-service AES-256 key held in the OS keyring and
// caches the derived AEAD in memory. See EncryptedFileStore for why only the
// key lives in the keyring.
type masterKey struct {
store *KeyringStore[string]
mu sync.Mutex
aead cipher.AEAD // cached after the first successful load or create
}
// loadLocked returns the cached or keyring-held AEAD. It returns ErrNotFound
// unwrapped when no key exists yet so callers can distinguish "no key" from
// "keyring unavailable". m.mu must be held.
func (m *masterKey) loadLocked() (cipher.AEAD, error) {
if m.aead != nil {
return m.aead, nil
}
encoded, err := m.store.Load(masterKeyUser)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, err
}
// e.g. Linux headless without Secret Service, or keyring locked.
return nil, fmt.Errorf("failed to read master key: %w", err)
}
key, decodeErr := base64.StdEncoding.DecodeString(encoded)
if decodeErr != nil || len(key) != masterKeySize {
return nil, errors.New("corrupted master key in keyring")
}
return m.cacheLocked(key)
}
// cacheLocked builds the AEAD for key and caches it. m.mu must be held.
func (m *masterKey) cacheLocked(key []byte) (cipher.AEAD, error) {
aead, err := newGCM(key)
if err != nil {
return nil, err
}
m.aead = aead
return aead, nil
}
// load returns the AEAD without ever creating a key, so decryption paths
// cannot mint a key that has no chance of opening existing ciphertext.
func (m *masterKey) load() (cipher.AEAD, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.loadLocked()
}
// get returns the AEAD, generating and persisting a new key on first use.
func (m *masterKey) get() (cipher.AEAD, error) {
m.mu.Lock()
defer m.mu.Unlock()
aead, err := m.loadLocked()
if err == nil {
return aead, nil
}
if !errors.Is(err, ErrNotFound) {
return nil, err
}
// First use: generate and persist a new key.
key := make([]byte, masterKeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("failed to generate master key: %w", err)
}
if err := m.store.Save(masterKeyUser, base64.StdEncoding.EncodeToString(key)); err != nil {
return nil, fmt.Errorf("failed to store master key: %w", err)
}
return m.cacheLocked(key)
}
// available reports whether the keyring can serve the master key without
// creating one: a cached or stored valid key counts, and so does a clean
// not-found (the key is generated lazily on first Save). A corrupted key or
// an unreachable keyring does not.
func (m *masterKey) available() bool {
m.mu.Lock()
defer m.mu.Unlock()
_, err := m.loadLocked()
return err == nil || errors.Is(err, ErrNotFound)
}
// newGCM creates an AES-256-GCM AEAD for the given key.
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
return gcm, nil
}
// sealValue encrypts plaintext with AES-256-GCM and returns
// "v1:" + base64(nonce || ciphertext).
func sealValue(aead cipher.AEAD, plaintext string) (string, error) {
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
// Seal appends ciphertext+tag to nonce, so the stored value is self-contained.
sealed := aead.Seal(nonce, nonce, []byte(plaintext), nil)
return sealedPrefix + base64.StdEncoding.EncodeToString(sealed), nil
}
// openValue decrypts a value produced by sealValue.
func openValue(aead cipher.AEAD, encoded string) (string, error) {
rest, ok := strings.CutPrefix(encoded, sealedPrefix)
if !ok {
return "", errors.New("unrecognized encrypted value format")
}
data, err := base64.StdEncoding.DecodeString(rest)
if err != nil {
return "", fmt.Errorf("failed to decode encrypted value: %w", err)
}
if len(data) < aead.NonceSize() {
return "", errors.New("encrypted value too short")
}
nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():]
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
// Wrong key or tampered value — GCM authentication failed.
return "", fmt.Errorf("failed to decrypt value (key mismatch or tampering): %w", err)
}
return string(plaintext), nil
}
// encryptedCodec wraps an inner codec with AES-256-GCM encryption using a
// keyring-held master key.
type encryptedCodec[T any] struct {
inner Codec[T]
key *masterKey
}
// Encode encodes v with the inner codec and encrypts the result.
func (c encryptedCodec[T]) Encode(v T) (string, error) {
aead, err := c.key.get()
if err != nil {
return "", err
}
plaintext, err := c.inner.Encode(v)
if err != nil {
return "", err
}
return sealValue(aead, plaintext)
}
// Decode decrypts s and decodes the plaintext with the inner codec.
func (c encryptedCodec[T]) Decode(s string) (T, error) {
var zero T
aead, err := c.key.load()
if err != nil {
if errors.Is(err, ErrNotFound) {
// Deliberately not wrapping ErrNotFound: the value exists but
// cannot be decrypted, which must not read as "no data stored".
return zero, errors.New("cannot decrypt stored value: master key not found in keyring")
}
return zero, err
}
plaintext, err := openValue(aead, s)
if err != nil {
return zero, err
}
return c.inner.Decode(plaintext)
}
// EncryptedFileStore stores values encrypted with AES-256-GCM in a JSON file,
// keeping only the 32-byte master key in the OS keyring. The keyring payload
// is a constant 44 bytes (base64) regardless of value size, so it never hits
// the Windows Credential Manager 2560-byte blob limit or the macOS/Linux
// keyring item size limits. The values themselves (which can be several KB
// for tokens with groups claims) are encrypted into a file with 0600
// permissions, file locking, and atomic writes.
//
// EncryptedFileStore implements Store[T] and Prober.
type EncryptedFileStore[T any] struct {
file *FileStore[T]
key *masterKey
}
// NewEncryptedFileStore creates an EncryptedFileStore. serviceName is the
// keyring service under which the master key is stored; filePath is the
// encrypted data file. Panics if codec is nil.
func NewEncryptedFileStore[T any](
serviceName, filePath string,
codec Codec[T],
) *EncryptedFileStore[T] {
if codec == nil {
panic("credstore: NewEncryptedFileStore called with nil codec")
}
key := &masterKey{store: NewStringKeyringStore(serviceName)}
return &EncryptedFileStore[T]{
file: NewFileStore[T](filePath, encryptedCodec[T]{inner: codec, key: key}),
key: key,
}
}
// Probe reports whether the OS keyring can serve the master key. It is
// read-only: the key itself is generated lazily on the first Save. Once the
// key is cached in memory, Probe keeps reporting true even if the keyring
// later becomes unavailable, because the store remains operational with the
// cached key.
func (e *EncryptedFileStore[T]) Probe() bool {
return e.key.available()
}
// Load loads and decrypts data for the given client ID.
func (e *EncryptedFileStore[T]) Load(clientID string) (T, error) {
return e.file.Load(clientID)
}
// Save encrypts and saves data for the given client ID.
func (e *EncryptedFileStore[T]) Save(clientID string, data T) error {
return e.file.Save(clientID, data)
}
// Delete removes data for the given client ID from the file.
func (e *EncryptedFileStore[T]) Delete(clientID string) error {
return e.file.Delete(clientID)
}
// String returns a description of this store.
func (e *EncryptedFileStore[T]) String() string {
return "encrypted-file: " + e.file.filePath
}
+140
View File
@@ -0,0 +1,140 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
// newLargeTestToken builds a token whose access token is several KB,
// mimicking real JWTs with large groups claims that exceed the Windows
// Credential Manager 2560-byte blob limit.
func newLargeTestToken(clientID string) Token {
return Token{
AccessToken: "header." + strings.Repeat("groups-claim-payload-", 300) + ".sig",
RefreshToken: "test-refresh-token",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
ClientID: clientID,
}
}
func newTestEncryptedStore(t *testing.T) (*EncryptedFileStore[Token], string) {
t.Helper()
keyring.MockInit()
path := filepath.Join(t.TempDir(), "tokens.enc")
return NewEncryptedFileStore[Token]("test-service", path, JSONCodec[Token]{}), path
}
func TestEncryptedFileStoreSaveAndLoad(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
assert.True(t, tok.ExpiresAt.Equal(loaded.ExpiresAt))
}
func TestEncryptedFileStoreFileContainsNoPlaintext(t *testing.T) {
store, path := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
raw, err := os.ReadFile(path)
require.NoError(t, err)
assert.NotContains(t, string(raw), "groups-claim-payload")
assert.NotContains(t, string(raw), tok.RefreshToken)
}
func TestEncryptedFileStoreKeyringHoldsOnlySmallMasterKey(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
// The token itself must not be in the keyring.
_, err := keyring.Get("test-service", "test-client")
assert.ErrorIs(t, err, keyring.ErrNotFound)
// Only the 44-byte base64 master key may live in the keyring —
// well under the Windows Credential Manager 2560-byte blob limit.
encoded, err := keyring.Get("test-service", masterKeyUser)
require.NoError(t, err)
assert.Len(t, encoded, 44)
key, err := base64.StdEncoding.DecodeString(encoded)
require.NoError(t, err)
assert.Len(t, key, 32)
}
func TestEncryptedFileStoreLoadNotFound(t *testing.T) {
store, _ := newTestEncryptedStore(t)
_, err := store.Load("nonexistent")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestEncryptedFileStoreDelete(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestEncryptedFileStoreSaveEmptyClientID(t *testing.T) {
store, _ := newTestEncryptedStore(t)
err := store.Save("", newLargeTestToken("x"))
assert.ErrorIs(t, err, ErrEmptyClientID)
}
func TestEncryptedFileStoreProbe(t *testing.T) {
store, _ := newTestEncryptedStore(t)
assert.True(t, store.Probe())
}
func TestEncryptedFileStoreCorruptMasterKeyFailsLoad(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
// A fresh store whose keyring holds a corrupted key must fail to decrypt
// rather than return garbage or mint a new key.
require.NoError(t, keyring.Set("test-service", masterKeyUser, "not-base64!"))
fresh := NewEncryptedFileStore[Token]("test-service", store.file.filePath, JSONCodec[Token]{})
assert.False(t, fresh.Probe())
_, err := fresh.Load("test-client")
require.Error(t, err)
assert.NotErrorIs(t, err, ErrNotFound)
}
func TestEncryptedFileStoreNilCodecPanics(t *testing.T) {
assert.Panics(t, func() {
NewEncryptedFileStore[string]("svc", "path", nil)
})
}
func TestEncryptedFileStoreString(t *testing.T) {
store, path := newTestEncryptedStore(t)
assert.Equal(t, "encrypted-file: "+path, store.String())
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"gitea.dev/tea/modules/filelock"
)
// storageMap manages encoded values for multiple clients.
type storageMap struct {
Data map[string]string `json:"data"` // clientID -> encoded value
}
// FileStore stores values in a JSON file with file locking and atomic writes.
type FileStore[T any] struct {
filePath string
codec Codec[T]
}
// NewFileStore creates a new FileStore with the given codec.
// Panics if codec is nil.
func NewFileStore[T any](filePath string, codec Codec[T]) *FileStore[T] {
if codec == nil {
panic("credstore: NewFileStore called with nil codec")
}
return &FileStore[T]{filePath: filePath, codec: codec}
}
// readStorageMap reads and unmarshals the storage map from the file.
// Returns an empty initialized map if the file does not exist.
func (f *FileStore[T]) readStorageMap() (storageMap, error) {
var m storageMap
data, err := os.ReadFile(f.filePath)
if err != nil {
if os.IsNotExist(err) {
m.Data = make(map[string]string)
return m, nil
}
return m, fmt.Errorf("failed to read file %q: %w", f.filePath, err)
}
if err := json.Unmarshal(data, &m); err != nil {
return m, fmt.Errorf("failed to parse file %q: %w", f.filePath, err)
}
if m.Data == nil {
m.Data = make(map[string]string)
}
return m, nil
}
// ensureDir creates the parent directory of the store file if it does not exist.
func (f *FileStore[T]) ensureDir() error {
if err := os.MkdirAll(filepath.Dir(f.filePath), 0o700); err != nil {
return fmt.Errorf("failed to create store directory: %w", err)
}
return nil
}
// writeStorageMap marshals and atomically writes the storage map to the file.
func (f *FileStore[T]) writeStorageMap(m storageMap) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tempFile := f.filePath + ".tmp"
if err := os.WriteFile(tempFile, data, 0o600); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// WriteFile only applies the mode when creating the file; enforce it in
// case a stale temp file with looser permissions was left behind.
if err := os.Chmod(tempFile, 0o600); err != nil {
_ = os.Remove(tempFile)
return fmt.Errorf("failed to set temp file permissions: %w", err)
}
if err := os.Rename(tempFile, f.filePath); err != nil {
_ = os.Remove(tempFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
// withFileLock acquires an exclusive cross-process lock on filePath+".lock",
// runs fn, and releases the lock. The kernel-level lock (flock/LockFileEx via
// modules/filelock) is released automatically if the process dies, so no
// stale-lock heuristics are needed. The .lock file itself remains on disk.
func (f *FileStore[T]) withFileLock(fn func() error) error {
return filelock.New(f.filePath+".lock", filelock.DefaultTimeout).WithLock(fn)
}
// Load loads data from the file for the given client ID.
// No file lock is needed: Save uses atomic rename, so reads always see a
// consistent snapshot on POSIX systems.
func (f *FileStore[T]) Load(clientID string) (T, error) {
var zero T
m, err := f.readStorageMap()
if err != nil {
return zero, err
}
encoded, ok := m.Data[clientID]
if !ok {
return zero, ErrNotFound
}
decoded, err := f.codec.Decode(encoded)
if err != nil {
return zero, fmt.Errorf("failed to decode value from %q: %w", f.filePath, err)
}
return decoded, nil
}
// Save saves data to the file for the given client ID.
// Uses file locking to prevent race conditions.
// Automatically creates parent directories if they do not exist.
func (f *FileStore[T]) Save(clientID string, data T) error {
if clientID == "" {
return ErrEmptyClientID
}
encoded, err := f.codec.Encode(data)
if err != nil {
return fmt.Errorf("failed to encode value for storage: %w", err)
}
if err := f.ensureDir(); err != nil {
return err
}
return f.withFileLock(func() error {
m, err := f.readStorageMap()
if err != nil {
return err
}
m.Data[clientID] = encoded
return f.writeStorageMap(m)
})
}
// Delete removes data for the given client ID from the file.
func (f *FileStore[T]) Delete(clientID string) error {
return f.withFileLock(func() error {
m, err := f.readStorageMap()
if err != nil {
return err
}
if _, ok := m.Data[clientID]; !ok {
return nil
}
delete(m.Data, clientID)
return f.writeStorageMap(m)
})
}
// String returns a description of this store.
func (f *FileStore[T]) String() string {
return "file: " + f.filePath
}
+220
View File
@@ -0,0 +1,220 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestFileStore(t *testing.T) (*FileStore[Token], string) {
t.Helper()
path := filepath.Join(t.TempDir(), "tokens.json")
return NewFileStore[Token](path, JSONCodec[Token]{}), path
}
func TestFileStoreSaveAndLoad(t *testing.T) {
store, _ := newTestFileStore(t)
tok := Token{
AccessToken: "test-access-token",
RefreshToken: "test-refresh-token",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
assert.Equal(t, tok.ClientID, loaded.ClientID)
}
func TestFileStoreLoadNotFound(t *testing.T) {
store, _ := newTestFileStore(t)
_, err := store.Load("nonexistent")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileStoreLoadFromExistingFileNotFound(t *testing.T) {
store, _ := newTestFileStore(t)
require.NoError(t, store.Save("client-1", Token{AccessToken: "token-1", ClientID: "client-1"}))
_, err := store.Load("client-2")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileStoreDelete(t *testing.T) {
store, _ := newTestFileStore(t)
require.NoError(t, store.Save("test-client", Token{AccessToken: "test-token", ClientID: "test-client"}))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileStoreDeleteNonexistent(t *testing.T) {
store, _ := newTestFileStore(t)
// Should not error when deleting from nonexistent file
assert.NoError(t, store.Delete("nonexistent"))
}
func TestFileStoreDeletePreservesOtherClients(t *testing.T) {
store, _ := newTestFileStore(t)
for _, id := range []string{"client-1", "client-2"} {
require.NoError(t, store.Save(id, Token{AccessToken: "token-" + id, ClientID: id}))
}
require.NoError(t, store.Delete("client-1"))
loaded, err := store.Load("client-2")
require.NoError(t, err)
assert.Equal(t, "token-client-2", loaded.AccessToken)
}
func TestFileStoreConcurrentWrites(t *testing.T) {
store, _ := newTestFileStore(t)
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func(id int) {
defer wg.Done()
tok := Token{
AccessToken: fmt.Sprintf("access-token-%d", id),
ClientID: fmt.Sprintf("client-%d", id),
}
assert.NoError(t, store.Save(tok.ClientID, tok))
}(i)
}
wg.Wait()
// Verify all tokens were saved by loading each one
for i := range goroutines {
clientID := fmt.Sprintf("client-%d", i)
loaded, err := store.Load(clientID)
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("access-token-%d", i), loaded.AccessToken)
}
// The kernel lock is released after the saves: the lock file (which
// legitimately remains on disk with flock-style locking) must be
// immediately re-lockable without hitting the timeout.
require.NoError(t, store.withFileLock(func() error { return nil }))
}
func TestFileStoreSaveEmptyClientID(t *testing.T) {
store, _ := newTestFileStore(t)
err := store.Save("", Token{AccessToken: "tok"})
assert.ErrorIs(t, err, ErrEmptyClientID)
}
func TestFileStoreFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file permission test is not applicable on Windows")
}
store, path := newTestFileStore(t)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}
func TestFileStoreSaveCreatesParentDirectories(t *testing.T) {
nestedPath := filepath.Join(t.TempDir(), "a", "b", "c", "tokens.json")
store := NewFileStore[Token](nestedPath, JSONCodec[Token]{})
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
loaded, err := store.Load("c1")
require.NoError(t, err)
assert.Equal(t, "tok", loaded.AccessToken)
}
func TestFileStoreInvalidJSON(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, os.WriteFile(path, []byte("{invalid"), 0o600))
_, err := store.Load("any")
assert.Error(t, err)
}
func TestFileStoreNullDataField(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, os.WriteFile(path, []byte(`{"data": null}`), 0o600))
// A null data map must read as empty, not crash.
_, err := store.Load("any")
assert.ErrorIs(t, err, ErrNotFound)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
}
func TestFileStoreWithFileLockPropagatesErrorAndReleases(t *testing.T) {
store, _ := newTestFileStore(t)
sentinel := fmt.Errorf("sentinel failure")
err := store.withFileLock(func() error { return sentinel })
assert.ErrorIs(t, err, sentinel)
// The lock must have been released despite the error: re-acquiring
// immediately must succeed without hitting the timeout.
assert.NoError(t, store.withFileLock(func() error { return nil }))
}
func TestFileStoreSaveLeavesNoTempFile(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
_, err := os.Stat(path + ".tmp")
assert.True(t, os.IsNotExist(err), "temp file left behind after successful save")
}
func TestFileStoreDeleteAbsentKeyDoesNotRewriteFile(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
before, err := os.ReadFile(path)
require.NoError(t, err)
// Deleting a key that is not present must be a no-op write-wise:
// other clients' data stays byte-identical on disk.
require.NoError(t, store.Delete("absent"))
after, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, string(before), string(after))
}
func TestFileStoreString(t *testing.T) {
store := NewFileStore[Token]("/path/to/tokens.json", JSONCodec[Token]{})
assert.Equal(t, "file: /path/to/tokens.json", store.String())
}
func TestFileStoreNilCodecPanics(t *testing.T) {
assert.Panics(t, func() {
NewFileStore[string]("path", nil)
})
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"errors"
"fmt"
"github.com/zalando/go-keyring"
)
// KeyringStore stores values in the OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager).
type KeyringStore[T any] struct {
serviceName string
codec Codec[T]
}
// NewKeyringStore creates a new KeyringStore with the given codec.
// Panics if codec is nil.
func NewKeyringStore[T any](serviceName string, codec Codec[T]) *KeyringStore[T] {
if codec == nil {
panic("credstore: NewKeyringStore called with nil codec")
}
return &KeyringStore[T]{serviceName: serviceName, codec: codec}
}
// Load loads data from the keyring for the given client ID.
func (k *KeyringStore[T]) Load(clientID string) (T, error) {
var zero T
data, err := keyring.Get(k.serviceName, clientID)
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return zero, ErrNotFound
}
return zero, fmt.Errorf("failed to read from keyring: %w", err)
}
decoded, err := k.codec.Decode(data)
if err != nil {
return zero, fmt.Errorf("failed to decode keyring data: %w", err)
}
return decoded, nil
}
// Save saves data to the keyring for the given client ID.
func (k *KeyringStore[T]) Save(clientID string, data T) error {
if clientID == "" {
return ErrEmptyClientID
}
encoded, err := k.codec.Encode(data)
if err != nil {
return fmt.Errorf("failed to encode data for keyring: %w", err)
}
if err := keyring.Set(k.serviceName, clientID, encoded); err != nil {
return fmt.Errorf("failed to save to keyring: %w", err)
}
return nil
}
// Delete removes data for the given client ID from the keyring.
func (k *KeyringStore[T]) Delete(clientID string) error {
err := keyring.Delete(k.serviceName, clientID)
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
return fmt.Errorf("failed to delete from keyring: %w", err)
}
return nil
}
// String returns a description of this store.
func (k *KeyringStore[T]) String() string {
return "keyring: " + k.serviceName
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
func TestKeyringStoreSaveAndLoad(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
require.NoError(t, store.Save("my-client", "eyJhbGciOiJSUzI1NiJ9"))
loaded, err := store.Load("my-client")
require.NoError(t, err)
assert.Equal(t, "eyJhbGciOiJSUzI1NiJ9", loaded)
}
func TestKeyringStoreLoadNotFound(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
_, err := store.Load("nonexistent")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestKeyringStoreDelete(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
require.NoError(t, store.Save("test-client", "test-token"))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestKeyringStoreDeleteNonexistent(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
// Should not error when deleting nonexistent key
assert.NoError(t, store.Delete("nonexistent"))
}
func TestKeyringStoreOverwriteExisting(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
require.NoError(t, store.Save("test-client", "token-v1"))
require.NoError(t, store.Save("test-client", "token-v2"))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, "token-v2", loaded)
}
func TestKeyringStoreSaveEmptyClientID(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
err := store.Save("", "tok")
assert.ErrorIs(t, err, ErrEmptyClientID)
}
func TestKeyringStoreString(t *testing.T) {
store := NewStringKeyringStore("my-service")
assert.Equal(t, "keyring: my-service", store.String())
}
func TestKeyringStoreNilCodecPanics(t *testing.T) {
assert.Panics(t, func() {
NewKeyringStore[string]("svc", nil)
})
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
// Prober is an optional interface that a Store can implement to test
// whether its backend is available.
type Prober interface {
Probe() bool
}
// DefaultSecureStore creates a SecureStore with the given codec and sensible defaults.
// The primary backend is an EncryptedFileStore writing to filePath+".enc"
// with its master key in the OS keyring; see EncryptedFileStore for why only
// the key lives there. When the keyring is unavailable, it falls back to
// plaintext file storage at filePath.
func DefaultSecureStore[T any](serviceName, filePath string, codec Codec[T]) *SecureStore[T] {
return NewSecureStore[T](
NewEncryptedFileStore[T](serviceName, filePath+".enc", codec),
NewFileStore[T](filePath, codec))
}
// SecureStore is a composite Store that uses the keyring-backed primary
// store when the keyring is available and falls back to file-based storage
// otherwise. The active backend is chosen once at construction time.
type SecureStore[T any] struct {
active Store[T]
}
// NewSecureStore creates a SecureStore. If kr implements Prober and the probe
// succeeds, kr is used as the active store. Otherwise, file is used as the
// fallback.
func NewSecureStore[T any](kr, file Store[T]) *SecureStore[T] {
if p, ok := kr.(Prober); ok && p.Probe() {
return &SecureStore[T]{active: kr}
}
return &SecureStore[T]{active: file}
}
// Load loads data from the active store.
func (s *SecureStore[T]) Load(clientID string) (T, error) {
return s.active.Load(clientID)
}
// Save saves data to the active store.
func (s *SecureStore[T]) Save(clientID string, data T) error {
return s.active.Save(clientID, data)
}
// Delete removes data from the active store.
func (s *SecureStore[T]) Delete(clientID string) error {
return s.active.Delete(clientID)
}
// String returns a description of the active store.
func (s *SecureStore[T]) String() string {
return s.active.String()
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
// mockStore is a simple mock implementing Store[T] for testing.
type mockStore[T any] struct {
data map[string]T
name string
}
func newMockStore[T any](name string) *mockStore[T] {
return &mockStore[T]{
data: make(map[string]T),
name: name,
}
}
func (m *mockStore[T]) Load(clientID string) (T, error) {
data, ok := m.data[clientID]
if !ok {
var zero T
return zero, ErrNotFound
}
return data, nil
}
func (m *mockStore[T]) Save(clientID string, data T) error {
m.data[clientID] = data
return nil
}
func (m *mockStore[T]) Delete(clientID string) error {
delete(m.data, clientID)
return nil
}
func (m *mockStore[T]) String() string {
return m.name
}
// mockProberStore implements both Store[T] and Prober.
type mockProberStore[T any] struct {
mockStore[T]
probeResult bool
}
func newMockProberStore[T any](name string, probeResult bool) *mockProberStore[T] {
return &mockProberStore[T]{
mockStore: mockStore[T]{data: make(map[string]T), name: name},
probeResult: probeResult,
}
}
func (m *mockProberStore[T]) Probe() bool {
return m.probeResult
}
func TestSecureStoreUsesKeyringWhenProbeSucceeds(t *testing.T) {
kr := newMockProberStore[Token]("keyring: test", true)
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
tok := Token{
AccessToken: "test-token",
ClientID: "test-client",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
require.NoError(t, store.Save(tok.ClientID, tok))
// Should be in keyring, not file
assert.Contains(t, kr.data, "test-client")
assert.NotContains(t, file.data, "test-client")
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, "test-token", loaded.AccessToken)
assert.Equal(t, "keyring: test", store.String())
}
func TestSecureStoreFallsBackToFileWhenProbeFails(t *testing.T) {
kr := newMockProberStore[Token]("keyring: test", false)
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
tok := Token{
AccessToken: "test-token",
ClientID: "test-client",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
require.NoError(t, store.Save(tok.ClientID, tok))
// Should be in file, not keyring
assert.Contains(t, file.data, "test-client")
assert.NotContains(t, kr.data, "test-client")
assert.Equal(t, "file: test", store.String())
}
func TestSecureStoreFallsBackWhenKrNotProber(t *testing.T) {
// kr does not implement Prober, should fall back to file
kr := newMockStore[Token]("keyring: test")
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
assert.Equal(t, "file: test", store.String())
}
func TestSecureStoreDelete(t *testing.T) {
kr := newMockProberStore[Token]("keyring: test", true)
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
tok := Token{
AccessToken: "test-token",
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
// TestDefaultTokenSecureStoreRoundTrip verifies the happy path: with a working
// keyring, Save writes AES-256-GCM ciphertext (v1: prefix) to filePath+".enc",
// Load returns the identical token, and Delete makes Load return ErrNotFound.
func TestDefaultTokenSecureStoreRoundTrip(t *testing.T) {
keyring.MockInit() // avoid touching the real OS keyring
plainPath := filepath.Join(t.TempDir(), "credentials.json")
store := DefaultTokenSecureStore("test-service", plainPath)
tok := Token{
AccessToken: "secret-access-token",
RefreshToken: "secret-refresh-token",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
assert.Equal(t, tok.TokenType, loaded.TokenType)
assert.Equal(t, tok.ClientID, loaded.ClientID)
assert.True(t, tok.ExpiresAt.Equal(loaded.ExpiresAt))
// The encrypted file must exist and contain only v1:-prefixed ciphertext.
raw, err := os.ReadFile(plainPath + ".enc")
require.NoError(t, err)
assert.Contains(t, string(raw), `"v1:`)
assert.NotContains(t, string(raw), "secret-access-token")
assert.NotContains(t, string(raw), "secret-refresh-token")
// No plaintext fallback file may be created.
_, err = os.Stat(plainPath)
assert.ErrorIs(t, err, os.ErrNotExist)
require.NoError(t, store.Delete("test-client"))
_, err = store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
// TestDefaultTokenSecureStoreFallbackWithoutKeyring verifies the CI/headless
// path: when the OS keyring is unavailable, the store falls back to the
// plaintext file and Save/Load still succeed.
func TestDefaultTokenSecureStoreFallbackWithoutKeyring(t *testing.T) {
keyring.MockInitWithError(errors.New("keyring unavailable"))
t.Cleanup(keyring.MockInit) // restore a working mock for later tests
plainPath := filepath.Join(t.TempDir(), "credentials.json")
store := DefaultTokenSecureStore("test-service", plainPath)
tok := Token{
AccessToken: "fallback-token",
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
assert.Equal(t, "file: "+plainPath, store.String())
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, "fallback-token", loaded.AccessToken)
// Plaintext file exists, encrypted file does not.
raw, err := os.ReadFile(plainPath)
require.NoError(t, err)
assert.True(t, strings.Contains(string(raw), "fallback-token"))
_, err = os.Stat(plainPath + ".enc")
assert.ErrorIs(t, err, os.ErrNotExist)
}
// Format-stability fixture: a fixed master key and a credentials.json.enc
// file in the "v1:" AES-256-GCM format. They must remain decryptable so
// users do not lose their stored tokens when upgrading tea.
const (
fixtureMasterKeyB64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
fixtureEncFile = `{
"data": {
"fixture-login": "v1:KallKg6+rJ3Sbxf6Kz1E5yF9bRgqq0Of00ZSctEY2Dem6qpm2wt9RdpCqSMdoX9AQ6/u9ujuC4a0LPb1n3ryXm0EJGrFpXHff0ukpatB1OZhdYlgcbuA8EFpPF/rSgN1hMXOXYQFn64r3iIEaXkgW69s887RNLbaxXALy3o7qvzmEWXuTPtEy3x+J4O6pmbDusvqgVWrOLPT9A1fSJnXWcViUcG13JF0X36NFPc149hsf1S0OUB2Uwn3hVl8jIISaw=="
}
}`
)
// TestDefaultTokenSecureStoreReadsFixtureData verifies on-disk format
// compatibility: the AES-256-GCM "v1:" ciphertext format (as produced by the
// original SDK implementation) with the master key in the keyring is
// decrypted correctly.
func TestDefaultTokenSecureStoreReadsFixtureData(t *testing.T) {
keyring.MockInit()
require.NoError(t, keyring.Set("tea-cli", masterKeyUser, fixtureMasterKeyB64))
plainPath := filepath.Join(t.TempDir(), "credentials.json")
require.NoError(t, os.WriteFile(plainPath+".enc", []byte(fixtureEncFile), 0o600))
store := DefaultTokenSecureStore("tea-cli", plainPath)
loaded, err := store.Load("fixture-login")
require.NoError(t, err)
assert.Equal(t, "fixture-access-token", loaded.AccessToken)
assert.Equal(t, "fixture-refresh-token", loaded.RefreshToken)
assert.Equal(t, "Bearer", loaded.TokenType)
assert.Equal(t, "fixture-login", loaded.ClientID)
assert.True(t, loaded.ExpiresAt.Equal(time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC)))
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"errors"
"time"
)
// ErrNotFound indicates that no data was found for the given client ID.
var ErrNotFound = errors.New("not found")
// ErrEmptyClientID is returned when an empty client ID is passed to Save.
var ErrEmptyClientID = errors.New("client ID cannot be empty")
// Store defines the interface for loading, saving, and deleting data by client ID.
type Store[T any] interface {
Load(clientID string) (T, error)
Save(clientID string, data T) error
Delete(clientID string) error
String() string
}
// Token represents saved tokens for a specific client.
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"`
IDToken string `json:"id_token,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
ClientID string `json:"client_id"`
}
// IsExpired reports whether the token has expired.
// Returns false if ExpiresAt is zero (token has no expiry).
func (t *Token) IsExpired() bool {
return !t.ExpiresAt.IsZero() && time.Now().After(t.ExpiresAt)
}
// IsValid reports whether the token has a non-empty access token and is not expired.
func (t *Token) IsValid() bool {
return t.AccessToken != "" && !t.IsExpired()
}
// NewStringKeyringStore creates a KeyringStore for plain string values.
func NewStringKeyringStore(serviceName string) *KeyringStore[string] {
return NewKeyringStore[string](serviceName, StringCodec{})
}
// DefaultTokenSecureStore creates a SecureStore for Token values with sensible defaults.
// Tokens are AES-256-GCM-encrypted to filePath+".enc" with the master key in
// the OS keyring; see DefaultSecureStore for details.
func DefaultTokenSecureStore(serviceName, filePath string) *SecureStore[Token] {
return DefaultSecureStore[Token](serviceName, filePath, JSONCodec[Token]{})
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestTokenIsExpired(t *testing.T) {
tests := []struct {
name string
expiresAt time.Time
want bool
}{
{
name: "expired token",
expiresAt: time.Now().Add(-1 * time.Hour),
want: true,
},
{
name: "not expired token",
expiresAt: time.Now().Add(1 * time.Hour),
want: false,
},
{
name: "zero expiry (no expiry)",
expiresAt: time.Time{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &Token{
AccessToken: "test-token",
ExpiresAt: tt.expiresAt,
}
assert.Equal(t, tt.want, token.IsExpired())
})
}
}
func TestTokenIsValid(t *testing.T) {
tests := []struct {
name string
accessToken string
expiresAt time.Time
want bool
}{
{
name: "valid token with future expiry",
accessToken: "test-token",
expiresAt: time.Now().Add(1 * time.Hour),
want: true,
},
{
name: "valid token with zero expiry",
accessToken: "test-token",
expiresAt: time.Time{},
want: true,
},
{
name: "expired token",
accessToken: "test-token",
expiresAt: time.Now().Add(-1 * time.Hour),
want: false,
},
{
name: "empty access token",
accessToken: "",
expiresAt: time.Now().Add(1 * time.Hour),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &Token{
AccessToken: tt.accessToken,
ExpiresAt: tt.expiresAt,
}
assert.Equal(t, tt.want, token.IsValid())
})
}
}