summaryrefslogtreecommitdiffstats
path: root/fikenauth/token.go
blob: dcda79ae93c740845b8484105643e1c13e5138de (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
// Package fikenauth implements Fiken's OAuth2 authorization code flow.
//
// It is kept apart from package fiken, which only knows how to put a bearer
// token in a header. Where that token came from — a personal API token typed
// into an environment variable, or a consent screen and a refresh token on
// disk — is this package's business and nobody else's.
package fikenauth

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"simenandre.no/fiken/internal/assert"
)

// ErrNoToken means nobody has logged in yet, or the stored token was removed.
var ErrNoToken = errors.New("not logged in to Fiken")

// expirySkew is how early a token is treated as expired. A token that dies
// halfway through an invoice run is worse than one refreshed a minute early.
const expirySkew = 2 * time.Minute

// maxTokenFileBytes bounds what will be read as a token file, so pointing the
// setting at the wrong path fails instead of consuming a database.
const maxTokenFileBytes = 64 << 10

// Token is a Fiken OAuth2 grant. It is written to disk, so the field names are
// part of the file format.
type Token struct {
	AccessToken  string    `json:"access_token"`
	RefreshToken string    `json:"refresh_token"`
	TokenType    string    `json:"token_type"`
	ExpiresAt    time.Time `json:"expires_at"`
}

// Valid reports whether the access token can still be used at now, allowing for
// the skew.
func (t Token) Valid(now time.Time) bool {
	return t.AccessToken != "" && now.Add(expirySkew).Before(t.ExpiresAt)
}

// Renewable reports whether a new access token can be obtained without asking
// the user to log in again.
func (t Token) Renewable() bool { return t.RefreshToken != "" }

// String deliberately hides the secrets. A token reaching a log line by way of
// %v is a credential leak, and this is the cheapest place to prevent it.
func (t Token) String() string {
	return fmt.Sprintf("fiken oauth token (expires %s)", t.ExpiresAt.Format(time.RFC3339))
}

// Store is where the token lives between runs: one file, readable only by its
// owner, outside the database.
type Store struct{ path string }

// NewStore returns a store backed by the file at path.
func NewStore(path string) *Store {
	assert.True(path != "", "token store path must not be empty")
	return &Store{path: path}
}

// Path is the file the token is kept in.
func (s *Store) Path() string { return s.path }

// Load reads the stored token, or returns ErrNoToken if there is none.
func (s *Store) Load() (Token, error) {
	info, err := os.Stat(s.path)
	if errors.Is(err, os.ErrNotExist) {
		return Token{}, ErrNoToken
	}
	if err != nil {
		return Token{}, fmt.Errorf("read %s: %w", s.path, err)
	}
	if info.Size() > maxTokenFileBytes {
		return Token{}, fmt.Errorf("%s is %d bytes, which is not a token file", s.path, info.Size())
	}

	raw, err := os.ReadFile(s.path)
	if err != nil {
		return Token{}, fmt.Errorf("read %s: %w", s.path, err)
	}
	var token Token
	if err := json.Unmarshal(raw, &token); err != nil {
		return Token{}, fmt.Errorf("read %s: %w; log in again to replace it", s.path, err)
	}
	if token.AccessToken == "" {
		return Token{}, ErrNoToken
	}
	return token, nil
}

// Save writes the token, replacing any previous one. The file is written to a
// temporary name and renamed, so an interrupted write cannot leave a truncated
// credential behind.
func (s *Store) Save(token Token) error {
	assert.True(token.AccessToken != "", "refusing to store an empty access token")

	dir := filepath.Dir(s.path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("create %s: %w", dir, err)
	}

	raw, err := json.MarshalIndent(token, "", "  ")
	if err != nil {
		return fmt.Errorf("encode token: %w", err)
	}

	temp, err := os.CreateTemp(dir, ".fiken-token-*")
	if err != nil {
		return fmt.Errorf("write %s: %w", s.path, err)
	}
	defer os.Remove(temp.Name()) //nolint:errcheck // already renamed on success

	if err := temp.Chmod(0o600); err != nil {
		temp.Close()
		return fmt.Errorf("write %s: %w", s.path, err)
	}
	if _, err := temp.Write(append(raw, '\n')); err != nil {
		temp.Close()
		return fmt.Errorf("write %s: %w", s.path, err)
	}
	if err := temp.Close(); err != nil {
		return fmt.Errorf("write %s: %w", s.path, err)
	}
	if err := os.Rename(temp.Name(), s.path); err != nil {
		return fmt.Errorf("write %s: %w", s.path, err)
	}
	return nil
}

// Delete removes the stored token. Deleting one that is not there succeeds:
// logging out twice is not an error.
func (s *Store) Delete() error {
	err := os.Remove(s.path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("remove %s: %w", s.path, err)
	}
	return nil
}