158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
|
// 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...)
|
||
|
}
|