Add a file-based session cache.

Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
Matt Moyer 2020-10-21 12:54:26 -05:00
parent e8113e3770
commit e919ef6582
No known key found for this signature in database
GPG Key ID: EAE88AD172C5AE2D
10 changed files with 1105 additions and 13 deletions

View File

@ -5,12 +5,16 @@ package cmd
import ( import (
"encoding/json" "encoding/json"
"os"
"path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
"k8s.io/klog/v2/klogr"
"go.pinniped.dev/internal/oidcclient" "go.pinniped.dev/internal/oidcclient"
"go.pinniped.dev/internal/oidcclient/filesession"
) )
//nolint: gochecknoinits //nolint: gochecknoinits
@ -26,23 +30,42 @@ func oidcLoginCommand(loginFunc func(issuer string, clientID string, opts ...oid
Short: "Login using an OpenID Connect provider", Short: "Login using an OpenID Connect provider",
SilenceUsage: true, SilenceUsage: true,
} }
issuer string issuer string
clientID string clientID string
listenPort uint16 listenPort uint16
scopes []string scopes []string
skipBrowser bool skipBrowser bool
sessionCachePath string
debugSessionCache bool
) )
cmd.Flags().StringVar(&issuer, "issuer", "", "OpenID Connect issuer URL.") cmd.Flags().StringVar(&issuer, "issuer", "", "OpenID Connect issuer URL.")
cmd.Flags().StringVar(&clientID, "client-id", "", "OpenID Connect client ID.") cmd.Flags().StringVar(&clientID, "client-id", "", "OpenID Connect client ID.")
cmd.Flags().Uint16Var(&listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only).") cmd.Flags().Uint16Var(&listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only).")
cmd.Flags().StringSliceVar(&scopes, "scopes", []string{"offline_access", "openid", "email", "profile"}, "OIDC scopes to request during login.") cmd.Flags().StringSliceVar(&scopes, "scopes", []string{"offline_access", "openid", "email", "profile"}, "OIDC scopes to request during login.")
cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL).") cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL).")
cmd.Flags().StringVar(&sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file.")
cmd.Flags().BoolVar(&debugSessionCache, "debug-session-cache", false, "Print debug logs related to the session cache.")
mustMarkHidden(&cmd, "debug-session-cache")
mustMarkRequired(&cmd, "issuer", "client-id") mustMarkRequired(&cmd, "issuer", "client-id")
cmd.RunE = func(cmd *cobra.Command, args []string) error { cmd.RunE = func(cmd *cobra.Command, args []string) error {
// Initialize the session cache.
var sessionOptions []filesession.Option
// If the hidden --debug-session-cache option is passed, log all the errors from the session cache with klog.
if debugSessionCache {
logger := klogr.New().WithName("session")
sessionOptions = append(sessionOptions, filesession.WithErrorReporter(func(err error) {
logger.Error(err, "error during session cache operation")
}))
}
sessionCache := filesession.New(sessionCachePath, sessionOptions...)
// Initialize the login handler.
opts := []oidcclient.Option{ opts := []oidcclient.Option{
oidcclient.WithContext(cmd.Context()), oidcclient.WithContext(cmd.Context()),
oidcclient.WithScopes(scopes), oidcclient.WithScopes(scopes),
oidcclient.WithSessionCache(sessionCache),
} }
if listenPort != 0 { if listenPort != 0 {
@ -76,3 +99,20 @@ func oidcLoginCommand(loginFunc func(issuer string, clientID string, opts ...oid
} }
return &cmd return &cmd
} }
// mustGetConfigDir returns a directory that follows the XDG base directory convention:
// $XDG_CONFIG_HOME defines the base directory relative to which user specific configuration files should
// be stored. If $XDG_CONFIG_HOME is either not set or empty, a default equal to $HOME/.config should be used.
// [1] https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
func mustGetConfigDir() string {
const xdgAppName = "pinniped"
if path := os.Getenv("XDG_CONFIG_HOME"); path != "" {
return filepath.Join(path, xdgAppName)
}
home, err := os.UserHomeDir()
if err != nil {
panic(err)
}
return filepath.Join(home, ".config", xdgAppName)
}

View File

@ -18,6 +18,8 @@ import (
func TestLoginOIDCCommand(t *testing.T) { func TestLoginOIDCCommand(t *testing.T) {
t.Parallel() t.Parallel()
cfgDir := mustGetConfigDir()
time1 := time.Date(3020, 10, 12, 13, 14, 15, 16, time.UTC) time1 := time.Date(3020, 10, 12, 13, 14, 15, 16, time.UTC)
tests := []struct { tests := []struct {
@ -40,12 +42,13 @@ func TestLoginOIDCCommand(t *testing.T) {
oidc --issuer ISSUER --client-id CLIENT_ID [flags] oidc --issuer ISSUER --client-id CLIENT_ID [flags]
Flags: Flags:
--client-id string OpenID Connect client ID. --client-id string OpenID Connect client ID.
-h, --help help for oidc -h, --help help for oidc
--issuer string OpenID Connect issuer URL. --issuer string OpenID Connect issuer URL.
--listen-port uint16 TCP port for localhost listener (authorization code flow only). --listen-port uint16 TCP port for localhost listener (authorization code flow only).
--scopes strings OIDC scopes to request during login. (default [offline_access,openid,email,profile]) --scopes strings OIDC scopes to request during login. (default [offline_access,openid,email,profile])
--skip-browser Skip opening the browser (just print the URL). --session-cache string Path to session cache file. (default "` + cfgDir + `/sessions.yaml")
--skip-browser Skip opening the browser (just print the URL).
`), `),
}, },
{ {
@ -64,7 +67,7 @@ func TestLoginOIDCCommand(t *testing.T) {
}, },
wantIssuer: "test-issuer", wantIssuer: "test-issuer",
wantClientID: "test-client-id", wantClientID: "test-client-id",
wantOptionsCount: 2, wantOptionsCount: 3,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
}, },
{ {
@ -74,10 +77,11 @@ func TestLoginOIDCCommand(t *testing.T) {
"--issuer", "test-issuer", "--issuer", "test-issuer",
"--skip-browser", "--skip-browser",
"--listen-port", "1234", "--listen-port", "1234",
"--debug-session-cache",
}, },
wantIssuer: "test-issuer", wantIssuer: "test-issuer",
wantClientID: "test-client-id", wantClientID: "test-client-id",
wantOptionsCount: 4, wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
}, },
} }

1
go.mod
View File

@ -10,6 +10,7 @@ require (
github.com/ghodss/yaml v1.0.0 github.com/ghodss/yaml v1.0.0
github.com/go-logr/logr v0.2.1 github.com/go-logr/logr v0.2.1
github.com/go-logr/stdr v0.2.0 github.com/go-logr/stdr v0.2.0
github.com/gofrs/flock v0.8.0
github.com/golang/mock v1.4.4 github.com/golang/mock v1.4.4
github.com/golangci/golangci-lint v1.31.0 github.com/golangci/golangci-lint v1.31.0
github.com/google/go-cmp v0.5.2 github.com/google/go-cmp v0.5.2

View File

@ -0,0 +1,157 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package cachefile implements the file format for session caches.
package filesession
import (
"errors"
"fmt"
"io/ioutil"
"os"
"reflect"
"sort"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
"go.pinniped.dev/internal/oidcclient"
)
var (
// errUnsupportedVersion is returned (internally) when we encounter a version of the session cache file that we
// don't understand how to handle (such as one produced by a future version of Pinniped).
errUnsupportedVersion = fmt.Errorf("unsupported session version")
)
const (
// apiVersion is the Kubernetes-style API version of the session file object.
apiVersion = "config.pinniped.dev/v1alpha1"
// apiKind is the Kubernetes-style Kind of the session file object.
apiKind = "SessionCache"
// sessionExpiration is how long a session can remain unused before it is automatically pruned from the session cache.
sessionExpiration = 90 * 24 * time.Hour
)
type (
// sessionCache is the object which is YAML-serialized to form the contents of the cache file.
sessionCache struct {
metav1.TypeMeta
Sessions []sessionEntry `json:"sessions"`
}
// sessionEntry is a single cache entry in the cache file.
sessionEntry struct {
Key oidcclient.SessionCacheKey `json:"key"`
CreationTimestamp metav1.Time `json:"creationTimestamp"`
LastUsedTimestamp metav1.Time `json:"lastUsedTimestamp"`
Tokens oidcclient.Token `json:"tokens"`
}
)
// readSessionCache loads a sessionCache from a path on disk. If the requested path does not exist, it returns an empty cache.
func readSessionCache(path string) (*sessionCache, error) {
cacheYAML, err := ioutil.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// If the file was not found, generate a freshly initialized empty cache.
return emptySessionCache(), nil
}
// Otherwise bubble up the error.
return nil, fmt.Errorf("could not read session file: %w", err)
}
// If we read the file successfully, unmarshal it from YAML.
var cache sessionCache
if err := yaml.Unmarshal(cacheYAML, &cache); err != nil {
return nil, fmt.Errorf("invalid session file: %w", err)
}
// Validate that we're reading a version of the config we understand how to parse.
if !(cache.TypeMeta.APIVersion == apiVersion && cache.TypeMeta.Kind == apiKind) {
return nil, fmt.Errorf("%w: %#v", errUnsupportedVersion, cache.TypeMeta)
}
return &cache, nil
}
// emptySessionCache returns an empty, initialized sessionCache.
func emptySessionCache() *sessionCache {
return &sessionCache{
TypeMeta: metav1.TypeMeta{APIVersion: apiVersion, Kind: apiKind},
Sessions: make([]sessionEntry, 0, 1),
}
}
// writeTo writes the cache to the specified file path.
func (c *sessionCache) writeTo(path string) error {
// Marshal the session back to YAML and save it to the file.
cacheYAML, err := yaml.Marshal(c)
if err == nil {
err = ioutil.WriteFile(path, cacheYAML, 0600)
}
return err
}
// normalized returns a copy of the sessionCache with stale entries removed and entries sorted in a canonical order.
func (c *sessionCache) normalized() *sessionCache {
result := emptySessionCache()
// Clean up expired/invalid tokens.
now := time.Now()
result.Sessions = make([]sessionEntry, 0, len(c.Sessions))
for _, s := range c.Sessions {
// Nil out any tokens that are empty or expired.
if s.Tokens.IDToken != nil {
if s.Tokens.IDToken.Token == "" || s.Tokens.IDToken.Expiry.Time.Before(now) {
s.Tokens.IDToken = nil
}
}
if s.Tokens.AccessToken != nil {
if s.Tokens.AccessToken.Token == "" || s.Tokens.AccessToken.Expiry.Time.Before(now) {
s.Tokens.AccessToken = nil
}
}
if s.Tokens.RefreshToken != nil && s.Tokens.RefreshToken.Token == "" {
s.Tokens.RefreshToken = nil
}
// Filter out any entries that no longer contain any tokens.
if s.Tokens.IDToken == nil && s.Tokens.AccessToken == nil && s.Tokens.RefreshToken == nil {
continue
}
// Filter out entries that haven't been used in the last sessionExpiration.
cutoff := metav1.NewTime(now.Add(-1 * sessionExpiration))
if s.LastUsedTimestamp.Before(&cutoff) {
continue
}
result.Sessions = append(result.Sessions, s)
}
// Sort the sessions by creation time.
sort.SliceStable(result.Sessions, func(i, j int) bool {
return result.Sessions[i].CreationTimestamp.Before(&result.Sessions[j].CreationTimestamp)
})
return result
}
// lookup a cache entry by key. May return nil.
func (c *sessionCache) lookup(key oidcclient.SessionCacheKey) *sessionEntry {
for i := range c.Sessions {
if reflect.DeepEqual(c.Sessions[i].Key, key) {
return &c.Sessions[i]
}
}
return nil
}
// insert a cache entry.
func (c *sessionCache) insert(entries ...sessionEntry) {
c.Sessions = append(c.Sessions, entries...)
}

View File

@ -0,0 +1,261 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package filesession
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/internal/oidcclient"
)
// validSession should be the same data as `testdata/valid.yaml`.
var validSession = sessionCache{
TypeMeta: metav1.TypeMeta{APIVersion: "config.pinniped.dev/v1alpha1", Kind: "SessionCache"},
Sessions: []sessionEntry{
{
Key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
CreationTimestamp: metav1.NewTime(time.Date(2020, 10, 20, 18, 42, 7, 0, time.UTC).Local()),
LastUsedTimestamp: metav1.NewTime(time.Date(2020, 10, 20, 18, 45, 31, 0, time.UTC).Local()),
Tokens: oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "test-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(time.Date(2020, 10, 20, 19, 46, 30, 0, time.UTC).Local()),
},
IDToken: &oidcclient.IDToken{
Token: "test-id-token",
Expiry: metav1.NewTime(time.Date(2020, 10, 20, 19, 42, 07, 0, time.UTC).Local()),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token",
},
},
},
},
}
func TestReadSessionCache(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
want *sessionCache
wantErr string
}{
{
name: "does not exist",
path: "./testdata/does-not-exist.yaml",
want: &sessionCache{
TypeMeta: metav1.TypeMeta{APIVersion: "config.pinniped.dev/v1alpha1", Kind: "SessionCache"},
Sessions: []sessionEntry{},
},
},
{
name: "other file error",
path: "./testdata/",
wantErr: "could not read session file: read ./testdata/: is a directory",
},
{
name: "invalid YAML",
path: "./testdata/invalid.yaml",
wantErr: "invalid session file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type filesession.sessionCache",
},
{
name: "wrong version",
path: "./testdata/wrong-version.yaml",
wantErr: `unsupported session version: v1.TypeMeta{Kind:"NotASessionCache", APIVersion:"config.pinniped.dev/v2alpha6"}`,
},
{
name: "valid",
path: "./testdata/valid.yaml",
want: &validSession,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := readSessionCache(tt.path)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
require.Nil(t, got)
return
}
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, tt.want, got)
})
}
}
func TestEmptySessionCache(t *testing.T) {
t.Parallel()
got := emptySessionCache()
require.Equal(t, metav1.TypeMeta{APIVersion: "config.pinniped.dev/v1alpha1", Kind: "SessionCache"}, got.TypeMeta)
require.Equal(t, 0, len(got.Sessions))
require.Equal(t, 1, cap(got.Sessions))
}
func TestWriteTo(t *testing.T) {
t.Parallel()
t.Run("io error", func(t *testing.T) {
t.Parallel()
tmp := t.TempDir() + "/sessions.yaml"
require.NoError(t, os.Mkdir(tmp, 0700))
err := validSession.writeTo(tmp)
require.EqualError(t, err, "open "+tmp+": is a directory")
})
t.Run("success", func(t *testing.T) {
t.Parallel()
require.NoError(t, validSession.writeTo(t.TempDir()+"/sessions.yaml"))
})
}
func TestNormalized(t *testing.T) {
t.Parallel()
t.Run("empty", func(t *testing.T) {
t.Parallel()
require.Equal(t, emptySessionCache(), emptySessionCache().normalized())
})
t.Run("nonempty", func(t *testing.T) {
t.Parallel()
input := emptySessionCache()
now := time.Now()
input.Sessions = []sessionEntry{
// ID token is empty, but not nil.
{
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
IDToken: &oidcclient.IDToken{
Token: "",
Expiry: metav1.NewTime(now.Add(1 * time.Minute)),
},
},
},
// ID token is expired.
{
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
IDToken: &oidcclient.IDToken{
Token: "test-id-token",
Expiry: metav1.NewTime(now.Add(-1 * time.Minute)),
},
},
},
// Access token is empty, but not nil.
{
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "",
Expiry: metav1.NewTime(now.Add(1 * time.Minute)),
},
},
},
// Access token is expired.
{
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "test-access-token",
Expiry: metav1.NewTime(now.Add(-1 * time.Minute)),
},
},
},
// Refresh token is empty, but not nil.
{
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
RefreshToken: &oidcclient.RefreshToken{
Token: "",
},
},
},
// Session has a refresh token but it hasn't been used in >90 days.
{
LastUsedTimestamp: metav1.NewTime(now.AddDate(-1, 0, 0)),
Tokens: oidcclient.Token{
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token",
},
},
},
// Two entries that are still valid.
{
CreationTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token2",
},
},
},
{
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token1",
},
},
},
}
// Expect that all but the last two valid session are pruned, and that they're sorted.
require.Equal(t, &sessionCache{
TypeMeta: metav1.TypeMeta{APIVersion: "config.pinniped.dev/v1alpha1", Kind: "SessionCache"},
Sessions: []sessionEntry{
{
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token1",
},
},
},
{
CreationTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now),
Tokens: oidcclient.Token{
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token2",
},
},
},
},
}, input.normalized())
})
}
func TestLookup(t *testing.T) {
t.Parallel()
require.Nil(t, validSession.lookup(oidcclient.SessionCacheKey{}))
require.NotNil(t, validSession.lookup(oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
}))
}
func TestInsert(t *testing.T) {
t.Parallel()
c := emptySessionCache()
c.insert(sessionEntry{})
require.Len(t, c.Sessions, 1)
}

View File

@ -0,0 +1,146 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package filesession implements a simple YAML file-based login.sessionCache.
package filesession
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gofrs/flock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/internal/oidcclient"
)
const (
// defaultFileLockTimeout is how long we will wait trying to acquire the file lock on the session file before timing out.
defaultFileLockTimeout = 10 * time.Second
// defaultFileLockRetryInterval is how often we will poll while waiting for the file lock to become available.
defaultFileLockRetryInterval = 10 * time.Millisecond
)
// Option configures a cache in New().
type Option func(*Cache)
// WithErrorReporter is an Option that specifies a callback which will be invoked for each error reported during
// session cache operations. By default, these errors are silently ignored.
func WithErrorReporter(reporter func(error)) Option {
return func(c *Cache) {
c.errReporter = reporter
}
}
// New returns a login.SessionCache implementation backed by the specified file path.
func New(path string, options ...Option) *Cache {
lock := flock.New(path + ".lock")
c := Cache{
path: path,
trylockFunc: func() error {
ctx, cancel := context.WithTimeout(context.Background(), defaultFileLockTimeout)
defer cancel()
_, err := lock.TryLockContext(ctx, defaultFileLockRetryInterval)
return err
},
unlockFunc: lock.Unlock,
errReporter: func(_ error) {},
}
for _, opt := range options {
opt(&c)
}
return &c
}
type Cache struct {
path string
errReporter func(error)
trylockFunc func() error
unlockFunc func() error
}
// GetToken looks up the cached data for the given parameters. It may return nil if no valid matching session is cached.
func (c *Cache) GetToken(key oidcclient.SessionCacheKey) *oidcclient.Token {
// If the cache file does not exist, exit immediately with no error log
if _, err := os.Stat(c.path); errors.Is(err, os.ErrNotExist) {
return nil
}
// Read the cache and lookup the matching entry. If one exists, update its last used timestamp and return it.
var result *oidcclient.Token
c.withCache(func(cache *sessionCache) {
if entry := cache.lookup(key); entry != nil {
result = &entry.Tokens
entry.LastUsedTimestamp = metav1.Now()
}
})
return result
}
// PutToken stores the provided token into the session cache under the given parameters. It does not return an error
// but may silently fail to update the session cache.
func (c *Cache) PutToken(key oidcclient.SessionCacheKey, token *oidcclient.Token) {
// Create the cache directory if it does not exist.
if err := os.MkdirAll(filepath.Dir(c.path), 0700); err != nil && !errors.Is(err, os.ErrExist) {
c.errReporter(fmt.Errorf("could not create session cache directory: %w", err))
return
}
// Mutate the cache to upsert the new session entry.
c.withCache(func(cache *sessionCache) {
// Find the existing entry, if one exists
if match := cache.lookup(key); match != nil {
// Update the stored token.
match.Tokens = *token
match.LastUsedTimestamp = metav1.Now()
return
}
// If there's not an entry for this key, insert one.
now := metav1.Now()
cache.insert(sessionEntry{
Key: key,
CreationTimestamp: now,
LastUsedTimestamp: now,
Tokens: *token,
})
})
}
// withCache is an internal helper which locks, reads the cache, processes/mutates it with the provided function, then
// saves it back to the file.
func (c *Cache) withCache(transact func(*sessionCache)) {
// Grab the file lock so we have exclusive access to read the file.
if err := c.trylockFunc(); err != nil {
c.errReporter(fmt.Errorf("could not lock session file: %w", err))
return
}
// Unlock the file at the end of this call, bubbling up the error if things were otherwise successful.
defer func() {
if err := c.unlockFunc(); err != nil {
c.errReporter(fmt.Errorf("could not unlock session file: %w", err))
}
}()
// Try to read the existing cache.
cache, err := readSessionCache(c.path)
if err != nil {
// If that fails, fall back to resetting to a blank slate.
c.errReporter(fmt.Errorf("failed to read cache, resetting: %w", err))
cache = emptySessionCache()
}
// Process/mutate the session using the provided function.
transact(cache)
// Marshal the session back to YAML and save it to the file.
if err := cache.writeTo(c.path); err != nil {
c.errReporter(fmt.Errorf("could not write session cache: %w", err))
}
}

View File

@ -0,0 +1,455 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package filesession
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/internal/oidcclient"
)
func TestNew(t *testing.T) {
t.Parallel()
tmp := t.TempDir() + "/sessions.yaml"
c := New(tmp)
require.NotNil(t, c)
require.Equal(t, tmp, c.path)
require.NotNil(t, c.errReporter)
c.errReporter(fmt.Errorf("some error"))
}
func TestGetToken(t *testing.T) {
t.Parallel()
now := time.Now().Round(1 * time.Second)
tests := []struct {
name string
makeTestFile func(t *testing.T, tmp string)
trylockFunc func(*testing.T) error
unlockFunc func(*testing.T) error
key oidcclient.SessionCacheKey
want *oidcclient.Token
wantErrors []string
wantTestFile func(t *testing.T, tmp string)
}{
{
name: "not found",
key: oidcclient.SessionCacheKey{},
},
{
name: "file lock error",
makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, ioutil.WriteFile(tmp, []byte(""), 0600)) },
trylockFunc: func(t *testing.T) error { return fmt.Errorf("some lock error") },
unlockFunc: func(t *testing.T) error { require.Fail(t, "should not be called"); return nil },
key: oidcclient.SessionCacheKey{},
wantErrors: []string{"could not lock session file: some lock error"},
},
{
name: "invalid file",
makeTestFile: func(t *testing.T, tmp string) {
require.NoError(t, ioutil.WriteFile(tmp, []byte("invalid yaml"), 0600))
},
key: oidcclient.SessionCacheKey{},
wantErrors: []string{
"failed to read cache, resetting: invalid session file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type filesession.sessionCache",
},
},
{
name: "invalid file, fail to unlock",
makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, ioutil.WriteFile(tmp, []byte("invalid"), 0600)) },
trylockFunc: func(t *testing.T) error { return nil },
unlockFunc: func(t *testing.T) error { return fmt.Errorf("some unlock error") },
key: oidcclient.SessionCacheKey{},
wantErrors: []string{
"failed to read cache, resetting: invalid session file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type filesession.sessionCache",
"could not unlock session file: some unlock error",
},
},
{
name: "unreadable file",
makeTestFile: func(t *testing.T, tmp string) {
require.NoError(t, os.Mkdir(tmp, 0700))
},
key: oidcclient.SessionCacheKey{},
wantErrors: []string{
"failed to read cache, resetting: could not read session file: read TEMPFILE: is a directory",
"could not write session cache: open TEMPFILE: is a directory",
},
},
{
name: "valid file but cache miss",
makeTestFile: func(t *testing.T, tmp string) {
validCache := emptySessionCache()
validCache.insert(sessionEntry{
Key: oidcclient.SessionCacheKey{
Issuer: "not-the-test-issuer",
ClientID: "not-the-test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
Tokens: oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "test-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
IDToken: &oidcclient.IDToken{
Token: "test-id-token",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token",
},
},
})
require.NoError(t, validCache.writeTo(tmp))
},
key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
wantErrors: []string{},
},
{
name: "valid file with cache hit",
makeTestFile: func(t *testing.T, tmp string) {
validCache := emptySessionCache()
validCache.insert(sessionEntry{
Key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
Tokens: oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "test-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
IDToken: &oidcclient.IDToken{
Token: "test-id-token",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token",
},
},
})
require.NoError(t, validCache.writeTo(tmp))
},
key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
wantErrors: []string{},
want: &oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "test-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(1 * time.Hour).Local()),
},
IDToken: &oidcclient.IDToken{
Token: "test-id-token",
Expiry: metav1.NewTime(now.Add(1 * time.Hour).Local()),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "test-refresh-token",
},
},
wantTestFile: func(t *testing.T, tmp string) {
cache, err := readSessionCache(tmp)
require.NoError(t, err)
require.Len(t, cache.Sessions, 1)
require.Less(t, time.Since(cache.Sessions[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tmp := t.TempDir() + "/sessions.yaml"
if tt.makeTestFile != nil {
tt.makeTestFile(t, tmp)
}
// Initialize a cache with a reporter that collects errors
errors := errorCollector{t: t}
c := New(tmp, errors.collect())
if tt.trylockFunc != nil {
c.trylockFunc = func() error { return tt.trylockFunc(t) }
}
if tt.unlockFunc != nil {
c.unlockFunc = func() error { return tt.unlockFunc(t) }
}
got := c.GetToken(tt.key)
require.Equal(t, tt.want, got)
errors.require(tt.wantErrors, "TEMPFILE", tmp)
if tt.wantTestFile != nil {
tt.wantTestFile(t, tmp)
}
})
}
}
func TestPutToken(t *testing.T) {
t.Parallel()
now := time.Now().Round(1 * time.Second)
tests := []struct {
name string
makeTestFile func(t *testing.T, tmp string)
key oidcclient.SessionCacheKey
token *oidcclient.Token
wantErrors []string
wantTestFile func(t *testing.T, tmp string)
}{
{
name: "fail to create directory",
makeTestFile: func(t *testing.T, tmp string) {
require.NoError(t, ioutil.WriteFile(filepath.Dir(tmp), []byte{}, 0600))
},
wantErrors: []string{
"could not create session cache directory: mkdir TEMPDIR: not a directory",
},
},
{
name: "update to existing entry",
makeTestFile: func(t *testing.T, tmp string) {
validCache := emptySessionCache()
validCache.insert(sessionEntry{
Key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
Tokens: oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "old-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
IDToken: &oidcclient.IDToken{
Token: "old-id-token",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "old-refresh-token",
},
},
})
require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700))
require.NoError(t, validCache.writeTo(tmp))
},
key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
token: &oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "new-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
IDToken: &oidcclient.IDToken{
Token: "new-id-token",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "new-refresh-token",
},
},
wantTestFile: func(t *testing.T, tmp string) {
cache, err := readSessionCache(tmp)
require.NoError(t, err)
require.Len(t, cache.Sessions, 1)
require.Less(t, time.Since(cache.Sessions[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
require.Equal(t, oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "new-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
IDToken: &oidcclient.IDToken{
Token: "new-id-token",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "new-refresh-token",
},
}, cache.Sessions[0].Tokens)
},
},
{
name: "new entry",
makeTestFile: func(t *testing.T, tmp string) {
validCache := emptySessionCache()
validCache.insert(sessionEntry{
Key: oidcclient.SessionCacheKey{
Issuer: "not-the-test-issuer",
ClientID: "not-the-test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
Tokens: oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "old-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
IDToken: &oidcclient.IDToken{
Token: "old-id-token",
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "old-refresh-token",
},
},
})
require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700))
require.NoError(t, validCache.writeTo(tmp))
},
key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
token: &oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "new-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
IDToken: &oidcclient.IDToken{
Token: "new-id-token",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "new-refresh-token",
},
},
wantTestFile: func(t *testing.T, tmp string) {
cache, err := readSessionCache(tmp)
require.NoError(t, err)
require.Len(t, cache.Sessions, 2)
require.Less(t, time.Since(cache.Sessions[1].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
require.Equal(t, oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "new-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
IDToken: &oidcclient.IDToken{
Token: "new-id-token",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "new-refresh-token",
},
}, cache.Sessions[1].Tokens)
},
},
{
name: "error writing cache",
makeTestFile: func(t *testing.T, tmp string) {
require.NoError(t, os.MkdirAll(tmp, 0700))
// require.NoError(t, emptySessionCache().writeTo(tmp))
// require.NoError(t, os.Chmod(tmp, 0400))
},
key: oidcclient.SessionCacheKey{
Issuer: "test-issuer",
ClientID: "test-client-id",
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: "http://localhost:0/callback",
},
token: &oidcclient.Token{
AccessToken: &oidcclient.AccessToken{
Token: "new-access-token",
Type: "Bearer",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
IDToken: &oidcclient.IDToken{
Token: "new-id-token",
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
},
RefreshToken: &oidcclient.RefreshToken{
Token: "new-refresh-token",
},
},
wantErrors: []string{
"failed to read cache, resetting: could not read session file: read TEMPFILE: is a directory",
"could not write session cache: open TEMPFILE: is a directory",
},
wantTestFile: func(t *testing.T, tmp string) {
// cache, err := readSessionCache(tmp)
// require.NoError(t, err)
// require.Len(t, cache.Sessions, 0)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tmp := t.TempDir() + "/sessiondir/sessions.yaml"
if tt.makeTestFile != nil {
tt.makeTestFile(t, tmp)
}
// Initialize a cache with a reporter that collects errors
errors := errorCollector{t: t}
c := New(tmp, errors.collect())
c.PutToken(tt.key, tt.token)
errors.require(tt.wantErrors, "TEMPFILE", tmp, "TEMPDIR", filepath.Dir(tmp))
if tt.wantTestFile != nil {
tt.wantTestFile(t, tmp)
}
})
}
}
type errorCollector struct {
t *testing.T
saw []error
}
func (e *errorCollector) collect() Option {
return WithErrorReporter(func(err error) {
e.saw = append(e.saw, err)
})
}
func (e *errorCollector) require(want []string, subs ...string) {
require.Len(e.t, e.saw, len(want))
for i, w := range want {
for i := 0; i < len(subs); i += 2 {
w = strings.ReplaceAll(w, subs[i], subs[i+1])
}
require.EqualError(e.t, e.saw[i], w)
}
}

View File

@ -0,0 +1 @@
invalid YAML

View File

@ -0,0 +1,24 @@
apiVersion: config.pinniped.dev/v1alpha1
kind: SessionCache
sessions:
- creationTimestamp: "2020-10-20T18:42:07Z"
key:
clientID: test-client-id
issuer: test-issuer
redirect_uri: http://localhost:0/callback
scopes:
- email
- offline_access
- openid
- profile
lastUsedTimestamp: "2020-10-20T18:45:31Z"
tokens:
access:
expiryTimestamp: "2020-10-20T19:46:30Z"
token: test-access-token
type: Bearer
id:
expiryTimestamp: "2020-10-20T19:42:07Z"
token: test-id-token
refresh:
token: test-refresh-token

View File

@ -0,0 +1,3 @@
apiVersion: config.pinniped.dev/v2alpha6
kind: NotASessionCache
sessions: []