blob: a4b9f2ba41fc4cbfb764b5a89446a7f3030eab7f [file] [log] [blame]
// Copyright 2017 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package internal
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"sort"
"time"
"golang.org/x/oauth2"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry"
"go.chromium.org/luci/common/retry/transient"
)
const (
// GCAccessTokenMaxAge defines when to remove unused access tokens from the
// disk cache.
//
// We define "an access token" as an instance of oauth2.Token with
// RefreshToken set to "".
//
// If an access token expired older than GCAccessTokenMaxAge ago, it will be
// evicted from the cache (it is essentially garbage now anyway).
GCAccessTokenMaxAge = 2 * time.Hour
// GCRefreshTokenMaxAge defines when to remove unused refresh tokens from the
// disk cache.
//
// We define "a refresh token" as an instance of oauth2.Token with
// RefreshToken not set to "".
//
// Refresh tokens don't expire, but they get neglected and forgotten by users,
// staying on their disks forever. We remove such tokens if they haven't been
// used for more than two weeks.
//
// It essentially logs out the user on inactivity. We don't actively revoke
// evicted tokens though, since it's possible the user has copied the token
// and uses it elsewhere (it happens). Such token can always be revoked from
// Google Accounts page manually, if required.
GCRefreshTokenMaxAge = 14 * 24 * time.Hour
)
// DiskTokenCache implements TokenCache on top of a file.
//
// It uses single file to store all tokens. If multiple processes try to write
// to it at the same time, only one process wins (so some updates may be lost).
//
// TODO(vadimsh): Either use file locking or split the cache into multiple
// files to avoid concurrency issues.
//
// TODO(vadimsh): Once this implementation settles and is deployed everywhere,
// add a cleanup step that removes <cache_dir>/*.tok left from the previous
// version of this code.
type DiskTokenCache struct {
Context context.Context // for logging and timing
SecretsDir string
}
type cacheFile struct {
Cache []*cacheFileEntry `json:"cache"`
LastUpdate time.Time `json:"last_update"`
}
// cacheFileEntry holds one set of cached tokens.
//
// Implements custom JSON marshaling logic to round-trip unknown fields. This
// is useful when new fields are added by newer code, but the token cache is
// still used by older code. Extra fields are better left untouched by the older
// code.
type cacheFileEntry struct {
key CacheKey
token oauth2.Token
idToken string
email string
lastUpdate time.Time
extra map[string]*json.RawMessage
}
type keyPtr struct {
key string
ptr any
}
func (e *cacheFileEntry) structure() []keyPtr {
return []keyPtr{
{"key", &e.key},
{"token", &e.token},
{"id_token", &e.idToken},
{"email", &e.email},
{"last_update", &e.lastUpdate},
}
}
func (e *cacheFileEntry) UnmarshalJSON(data []byte) error {
*e = cacheFileEntry{extra: make(map[string]*json.RawMessage)}
if err := json.Unmarshal(data, &e.extra); err != nil {
return err
}
for _, kv := range e.structure() {
if raw := e.extra[kv.key]; raw != nil {
delete(e.extra, kv.key)
if err := json.Unmarshal([]byte(*raw), kv.ptr); err != nil {
return fmt.Errorf("when JSON decoding %q - %s", kv.key, err)
}
}
}
return nil
}
func (e *cacheFileEntry) MarshalJSON() ([]byte, error) {
// Note: this way of marshaling preserves the order of keys per structure().
// All unrecognized extra keys are placed at the end, sorted.
fields := e.structure()
if len(e.extra) != 0 {
l := len(fields)
for k, v := range e.extra {
fields = append(fields, keyPtr{k, v})
}
extra := fields[l:]
sort.Slice(extra, func(i, j int) bool { return extra[i].key < extra[j].key })
}
out := bytes.Buffer{}
out.WriteString("{")
first := true
for _, kv := range fields {
if !first {
out.WriteString(",")
}
first = false
fmt.Fprintf(&out, "%q:", kv.key)
if err := json.NewEncoder(&out).Encode(kv.ptr); err != nil {
return nil, fmt.Errorf("when JSON encoding %q - %s", kv.key, err)
}
}
out.WriteString("}")
return out.Bytes(), nil
}
func (e *cacheFileEntry) isOld(now time.Time) bool {
delay := GCAccessTokenMaxAge
if e.token.RefreshToken != "" {
delay = GCRefreshTokenMaxAge
}
exp := e.token.Expiry
if exp.IsZero() {
exp = e.lastUpdate
}
return now.Sub(exp.Round(0)) >= delay
}
func (c *DiskTokenCache) legacyPath() string {
return filepath.Join(c.SecretsDir, "creds.json")
}
func (c *DiskTokenCache) tokensPath() string {
return filepath.Join(c.SecretsDir, "tokens.json")
}
// readCacheFile loads the file with cached tokens.
func (c *DiskTokenCache) readCacheFile(path string) (*cacheFile, error) {
// Minimize the time the file is locked on Windows by reading it all at once
// and decoding later.
//
// We also need to open it with FILE_SHARE_DELETE sharing mode to allow
// writeCacheFile() below to replace open files (even though it tries to wait
// for the file to be closed). For some reason, omitting FILE_SHARE_DELETE
// flag causes random sharing violation errors when opening the file for
// reading.
f, err := openSharedDelete(path)
switch {
case os.IsNotExist(err):
return &cacheFile{}, nil
case err != nil:
return nil, err
}
blob, err := io.ReadAll(f)
f.Close()
if err != nil {
return nil, err
}
cache := &cacheFile{}
if err := json.Unmarshal(blob, cache); err != nil {
// If the cache file got broken somehow, it makes sense to treat it as
// empty (so it can later be overwritten), since it's unlikely it's going
// to "fix itself".
logging.Warningf(c.Context, "The token cache %s is broken: %s", path, err)
return &cacheFile{}, nil
}
return cache, nil
}
// writeCacheFile overwrites the file with cached tokens.
//
// Returns a transient error if the file is locked by some other process and
// can't be updated (this happens on Windows).
func (c *DiskTokenCache) writeCacheFile(path string, cache *cacheFile) error {
// Nothing left? Remove the file completely.
if len(cache.Cache) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
blob, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return err
}
// Write to temp file first.
if err := os.MkdirAll(c.SecretsDir, 0700); err != nil {
logging.WithError(err).Warningf(c.Context, "Failed to mkdir token cache dir")
// carry on, TempFile will fail too.
}
tmp, err := ioutil.TempFile(c.SecretsDir, "tokens.json.*")
if err != nil {
return err
}
cleanup := func() {
if err := os.Remove(tmp.Name()); err != nil {
logging.WithError(err).Warningf(c.Context, "Failed to remove temp creds cache file: %s", tmp.Name())
}
}
_, writeErr := tmp.Write(blob)
closeErr := tmp.Close()
switch {
case writeErr != nil:
err = writeErr
case closeErr != nil:
err = closeErr
}
if err != nil {
cleanup()
return err
}
// Note that TempFile creates the file in 0600 mode already, so we don't need
// to chmod it.
//
// On Windows Rename may fail with sharing violation error if some other
// process has opened the file. We treat it as transient error, to trigger
// a retry in updateCacheFile.
if err = os.Rename(tmp.Name(), path); err != nil {
cleanup()
return transient.Tag.Apply(err)
}
return nil
}
// updateCache reads token cache files, calls the callback and writes the files
// back if the callback returns 'true'.
//
// It retries a bunch of times when encountering sharing violation errors on
// Windows.
//
// Mutates tokens.json and creds.json. tokens.json is the primary token cache
// file and creds.json is an old one used by the older versions of this library.
// It will eventually be phased out.
//
// TODO(vadimsh): Change this to use file locking - updateCacheFile is a global
// critical section.
func (c *DiskTokenCache) updateCache(cb func(*cacheFile, time.Time) bool) error {
retryParams := func() retry.Iterator {
return &retry.ExponentialBackoff{
Limited: retry.Limited{
Delay: 10 * time.Millisecond,
Retries: 200,
MaxTotal: 4 * time.Second,
},
Multiplier: 1.5,
}
}
return retry.Retry(c.Context, transient.Only(retryParams), func() error {
return c.updateCacheFiles(cb)
}, func(err error, _ time.Duration) {
logging.Warningf(c.Context, "Retrying the failed token cache update: %s", err)
})
}
// readCache reads tokens.json and creds.json and merges them.
func (c *DiskTokenCache) readCache() (*cacheFile, time.Time, error) {
legacyCache, err := c.readCacheFile(c.legacyPath())
if err != nil {
return nil, time.Time{}, err
}
newCache, err := c.readCacheFile(c.tokensPath())
if err != nil {
return nil, time.Time{}, err
}
// Merge tokens from legacyCache into newCache, but don't override anything.
seen := stringset.New(len(newCache.Cache))
for _, entry := range newCache.Cache {
seen.Add(entry.key.ToMapKey())
}
for _, entry := range legacyCache.Cache {
if !seen.Has(entry.key.ToMapKey()) {
newCache.Cache = append(newCache.Cache, entry)
}
}
// If legacyCache didn't exist at all, pretend it was touched in distant past.
// This avoid weird looking "0001-01-01" dates. Seventies were better.
if legacyCache.LastUpdate.IsZero() {
legacyCache.LastUpdate = time.Date(1970, time.January, 01, 0, 0, 0, 0, time.UTC)
}
return newCache, legacyCache.LastUpdate, nil
}
// updateCacheFiles does one attempt at updating the cache files.
func (c *DiskTokenCache) updateCacheFiles(cb func(*cacheFile, time.Time) bool) error {
// Read and merge tokens.json and creds.json.
cache, legacyLastUpdate, err := c.readCache()
if err != nil {
return err
}
// Apply the mutation.
now := clock.Now(c.Context).UTC()
if !cb(cache, now) {
return nil
}
// Tidy up the cache before saving it.
c.discardOldEntries(cache, now)
// HACK: Update creds.json, but do not touch its "last_update" time. That way
// refresh tokens created by newer `cipd auth-login ...` would still work with
// older binaries that look at creds.json, but there's still a way to know
// when creds.json is not actually used (its `last_update` time would be
// ancient). This will eventually be used to decide if it is safe to delete
// creds.json.
cache.LastUpdate = legacyLastUpdate
if err := c.writeCacheFile(c.legacyPath(), cache); err != nil {
return err
}
// Update tokens.json as usual, updating its `last_update` field.
cache.LastUpdate = now
return c.writeCacheFile(c.tokensPath(), cache)
}
// discardOldEntries filters out old entries.
func (c *DiskTokenCache) discardOldEntries(cache *cacheFile, now time.Time) {
filtered := cache.Cache[:0]
for _, entry := range cache.Cache {
if !entry.isOld(now) {
filtered = append(filtered, entry)
} else {
logging.Debugf(c.Context, "Cleaning up old token cache entry: %s", entry.key.Key)
}
}
cache.Cache = filtered
}
// GetToken reads the token from cache.
func (c *DiskTokenCache) GetToken(key *CacheKey) (*Token, error) {
cache, _, err := c.readCache()
if err != nil {
return nil, err
}
for _, entry := range cache.Cache {
if EqualCacheKeys(&entry.key, key) {
return &Token{
Token: entry.token,
IDToken: entry.idToken,
Email: entry.email,
}, nil
}
}
return nil, nil
}
// PutToken writes the token to cache.
func (c *DiskTokenCache) PutToken(key *CacheKey, tok *Token) error {
token := tok.Token
if !token.Expiry.IsZero() {
token.Expiry = token.Expiry.UTC()
}
return c.updateCache(func(cache *cacheFile, now time.Time) bool {
for _, entry := range cache.Cache {
if EqualCacheKeys(&entry.key, key) {
entry.token = token
entry.idToken = tok.IDToken
entry.email = tok.Email
entry.lastUpdate = now
return true
}
}
cache.Cache = append(cache.Cache, &cacheFileEntry{
key: *key,
token: token,
idToken: tok.IDToken,
email: tok.Email,
lastUpdate: now,
})
return true
})
}
// DeleteToken removes the token from cache.
func (c *DiskTokenCache) DeleteToken(key *CacheKey) error {
return c.updateCache(func(cache *cacheFile, now time.Time) bool {
for i, entry := range cache.Cache {
if EqualCacheKeys(&entry.key, key) {
cache.Cache = append(cache.Cache[:i], cache.Cache[i+1:]...)
return true
}
}
return false // not there, this is fine, skip writing the file
})
}