From f6d939a8dfc4f820abe814067dfe7da15efcd8d8 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 3 Aug 2026 21:59:07 +0800 Subject: [PATCH] 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 --- go.mod | 3 +- go.sum | 2 - modules/config/credstore.go | 3 +- modules/credstore/codec.go | 49 ++++ modules/credstore/doc.go | 8 + modules/credstore/encrypted_store.go | 259 ++++++++++++++++++++++ modules/credstore/encrypted_store_test.go | 140 ++++++++++++ modules/credstore/file_store.go | 171 ++++++++++++++ modules/credstore/file_store_test.go | 220 ++++++++++++++++++ modules/credstore/keyring_store.go | 76 +++++++ modules/credstore/keyring_store_test.go | 81 +++++++ modules/credstore/secure_store.go | 58 +++++ modules/credstore/secure_store_test.go | 242 ++++++++++++++++++++ modules/credstore/store.go | 57 +++++ modules/credstore/store_test.go | 89 ++++++++ 15 files changed, 1453 insertions(+), 5 deletions(-) create mode 100644 modules/credstore/codec.go create mode 100644 modules/credstore/doc.go create mode 100644 modules/credstore/encrypted_store.go create mode 100644 modules/credstore/encrypted_store_test.go create mode 100644 modules/credstore/file_store.go create mode 100644 modules/credstore/file_store_test.go create mode 100644 modules/credstore/keyring_store.go create mode 100644 modules/credstore/keyring_store_test.go create mode 100644 modules/credstore/secure_store.go create mode 100644 modules/credstore/secure_store_test.go create mode 100644 modules/credstore/store.go create mode 100644 modules/credstore/store_test.go diff --git a/go.mod b/go.mod index dcb30bc3..4b8838d8 100644 --- a/go.mod +++ b/go.mod @@ -12,13 +12,13 @@ require ( github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 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/olekukonko/tablewriter v1.1.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 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/oauth2 v0.36.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/yuin/goldmark v1.8.2 // 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/net v0.56.0 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/go.sum b/go.sum index 59d211db..1346fd9d 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= diff --git a/modules/config/credstore.go b/modules/config/credstore.go index 2a87ce7a..ca6c01b5 100644 --- a/modules/config/credstore.go +++ b/modules/config/credstore.go @@ -8,8 +8,9 @@ import ( "sync" "time" + "gitea.dev/tea/modules/credstore" + "github.com/adrg/xdg" - "github.com/go-signet/sdk-go/credstore" "golang.org/x/oauth2" ) diff --git a/modules/credstore/codec.go b/modules/credstore/codec.go new file mode 100644 index 00000000..bc68a071 --- /dev/null +++ b/modules/credstore/codec.go @@ -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 +} diff --git a/modules/credstore/doc.go b/modules/credstore/doc.go new file mode 100644 index 00000000..02980007 --- /dev/null +++ b/modules/credstore/doc.go @@ -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 diff --git a/modules/credstore/encrypted_store.go b/modules/credstore/encrypted_store.go new file mode 100644 index 00000000..97ea7c8b --- /dev/null +++ b/modules/credstore/encrypted_store.go @@ -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 +} diff --git a/modules/credstore/encrypted_store_test.go b/modules/credstore/encrypted_store_test.go new file mode 100644 index 00000000..cbc439f7 --- /dev/null +++ b/modules/credstore/encrypted_store_test.go @@ -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()) +} diff --git a/modules/credstore/file_store.go b/modules/credstore/file_store.go new file mode 100644 index 00000000..60cea451 --- /dev/null +++ b/modules/credstore/file_store.go @@ -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 +} diff --git a/modules/credstore/file_store_test.go b/modules/credstore/file_store_test.go new file mode 100644 index 00000000..0f3f5313 --- /dev/null +++ b/modules/credstore/file_store_test.go @@ -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) + }) +} diff --git a/modules/credstore/keyring_store.go b/modules/credstore/keyring_store.go new file mode 100644 index 00000000..a6066c8c --- /dev/null +++ b/modules/credstore/keyring_store.go @@ -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 +} diff --git a/modules/credstore/keyring_store_test.go b/modules/credstore/keyring_store_test.go new file mode 100644 index 00000000..be0f7718 --- /dev/null +++ b/modules/credstore/keyring_store_test.go @@ -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) + }) +} diff --git a/modules/credstore/secure_store.go b/modules/credstore/secure_store.go new file mode 100644 index 00000000..210b934c --- /dev/null +++ b/modules/credstore/secure_store.go @@ -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() +} diff --git a/modules/credstore/secure_store_test.go b/modules/credstore/secure_store_test.go new file mode 100644 index 00000000..677e5b3c --- /dev/null +++ b/modules/credstore/secure_store_test.go @@ -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))) +} diff --git a/modules/credstore/store.go b/modules/credstore/store.go new file mode 100644 index 00000000..46299dfb --- /dev/null +++ b/modules/credstore/store.go @@ -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]{}) +} diff --git a/modules/credstore/store_test.go b/modules/credstore/store_test.go new file mode 100644 index 00000000..60b9292b --- /dev/null +++ b/modules/credstore/store_test.go @@ -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()) + }) + } +}