feat: Phase 1 — config, auth, OAuth2 PKCE, CLI scaffold, token store
This commit is contained in:
78
internal/store/tokenstore.go
Normal file
78
internal/store/tokenstore.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TokenSet struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
func (t *TokenSet) IsExpired() bool {
|
||||
if t.Expiry.IsZero() {
|
||||
return false
|
||||
}
|
||||
return time.Now().After(t.Expiry)
|
||||
}
|
||||
|
||||
type TokenStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func NewTokenStore(path string) *TokenStore {
|
||||
return &TokenStore{path: path}
|
||||
}
|
||||
|
||||
func (s *TokenStore) Save(token *TokenSet) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
|
||||
return fmt.Errorf("create token dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal token: %w", err)
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return fmt.Errorf("write token file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return fmt.Errorf("rename token file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TokenStore) Load() (*TokenSet, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read token file: %w", err)
|
||||
}
|
||||
var token TokenSet
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal token: %w", err)
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func (s *TokenStore) Delete() error {
|
||||
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete token file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TokenStore) Exists() bool {
|
||||
_, err := os.Stat(s.path)
|
||||
return err == nil
|
||||
}
|
||||
78
internal/store/tokenstore_test.go
Normal file
78
internal/store/tokenstore_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenStoreSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tokens.json")
|
||||
s := NewTokenStore(path)
|
||||
|
||||
token := &TokenSet{
|
||||
AccessToken: "access-abc",
|
||||
RefreshToken: "refresh-xyz",
|
||||
TokenType: "Bearer",
|
||||
Expiry: time.Now().Add(time.Hour).Truncate(time.Second),
|
||||
Scopes: []string{"https://www.googleapis.com/auth/calendar"},
|
||||
Account: "test@example.com",
|
||||
}
|
||||
|
||||
require.NoError(t, s.Save(token))
|
||||
|
||||
info, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
|
||||
|
||||
loaded, err := s.Load()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
assert.Equal(t, token.AccessToken, loaded.AccessToken)
|
||||
assert.Equal(t, token.RefreshToken, loaded.RefreshToken)
|
||||
assert.Equal(t, token.Account, loaded.Account)
|
||||
}
|
||||
|
||||
func TestTokenStoreLoadMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewTokenStore(filepath.Join(dir, "noexist.json"))
|
||||
|
||||
token, err := s.Load()
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, token)
|
||||
}
|
||||
|
||||
func TestTokenStoreDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewTokenStore(filepath.Join(dir, "tokens.json"))
|
||||
|
||||
token := &TokenSet{AccessToken: "abc"}
|
||||
require.NoError(t, s.Save(token))
|
||||
assert.True(t, s.Exists())
|
||||
|
||||
require.NoError(t, s.Delete())
|
||||
assert.False(t, s.Exists())
|
||||
assert.NoError(t, s.Delete())
|
||||
}
|
||||
|
||||
func TestTokenIsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token TokenSet
|
||||
expired bool
|
||||
}{
|
||||
{"no expiry", TokenSet{}, false},
|
||||
{"future expiry", TokenSet{Expiry: time.Now().Add(time.Hour)}, false},
|
||||
{"past expiry", TokenSet{Expiry: time.Now().Add(-time.Hour)}, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expired, tc.token.IsExpired())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user