Skip to content

Commit c024772

Browse files
committed
feat(armadactl): add OIDC refresh token caching
Add token caching for OIDC auth to avoid repeated browser authentication. The refresh token is securely stored in the system keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service). To enable, add `cacheRefreshToken: true` to your context in ~/.armadactl.yaml and include `offline_access` in your scopes. Note: armadactl must be built with CGO_ENABLED=1 on macOS for keychain access. Signed-off-by: Dejan Zele Pejchev <[email protected]>
1 parent 47dfb7a commit c024772

File tree

9 files changed

+232
-19
lines changed

9 files changed

+232
-19
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ jobs:
131131
DOCKER_REPO: "gresearch"
132132
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
133133
DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}"
134+
134135
invoke-chart-push:
135136
name: Invoke Chart push
136137
needs: release

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ archives:
157157
- README.md
158158
- MAINTAINERS.md
159159

160-
# macOS Universal Binaries-*
160+
# macOS Universal Binaries
161161
universal_binaries:
162162
- replace: true
163163
id: armadactl

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ require (
7676
github.com/rs/zerolog v1.33.0
7777
github.com/segmentio/fasthash v1.0.3
7878
github.com/xitongsys/parquet-go v1.6.2
79+
github.com/zalando/go-keyring v0.2.6
7980
go.uber.org/atomic v1.11.0
8081
go.uber.org/mock v0.5.0
8182
golang.org/x/term v0.36.0
@@ -87,6 +88,7 @@ require (
8788
)
8889

8990
require (
91+
al.essio.dev/pkg/shellescape v1.5.1 // indirect
9092
dario.cat/mergo v1.0.1 // indirect
9193
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
9294
github.com/99designs/keyring v1.2.1 // indirect
@@ -113,7 +115,7 @@ require (
113115
github.com/charmbracelet/x/ansi v0.8.0 // indirect
114116
github.com/cloudflare/circl v1.3.8 // indirect
115117
github.com/cyphar/filepath-securejoin v0.3.6 // indirect
116-
github.com/danieljoos/wincred v1.1.2 // indirect
118+
github.com/danieljoos/wincred v1.2.2 // indirect
117119
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
118120
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
119121
github.com/dlclark/regexp2 v1.11.0 // indirect
@@ -148,6 +150,7 @@ require (
148150
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
149151
github.com/gobwas/glob v0.2.3 // indirect
150152
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
153+
github.com/godbus/dbus/v5 v5.1.0 // indirect
151154
github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a // indirect
152155
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
153156
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect

go.sum

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
2+
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
13
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
24
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
35
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -141,8 +143,8 @@ github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf
141143
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
142144
github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM=
143145
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
144-
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
145-
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
146+
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
147+
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
146148
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
147149
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
148150
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -274,6 +276,8 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
274276
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
275277
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
276278
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
279+
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
280+
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
277281
github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a h1:dR8+Q0uO5S2ZBcs2IH6VBKYwSxPo2vYCYq0ot0mu7xA=
278282
github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
279283
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -666,6 +670,8 @@ github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhb
666670
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
667671
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
668672
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
673+
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
674+
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
669675
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
670676
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
671677
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
@@ -803,7 +809,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
803809
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
804810
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
805811
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
806-
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
807812
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
808813
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
809814
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

pkg/client/auth/oidc/cache.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package oidc
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"time"
9+
10+
"github.com/zalando/go-keyring"
11+
"golang.org/x/oauth2"
12+
13+
log "github.com/armadaproject/armada/internal/common/logging"
14+
)
15+
16+
const (
17+
keyringServiceName = "armada-oidc"
18+
)
19+
20+
var errCacheNotInitialized = errors.New("token cache not initialized")
21+
22+
type tokenCache struct {
23+
providerUrl string
24+
clientId string
25+
}
26+
27+
type cachedToken struct {
28+
RefreshToken string `json:"refresh_token"`
29+
}
30+
31+
func newTokenCache(providerUrl, clientId string) (*tokenCache, error) {
32+
return &tokenCache{
33+
providerUrl: providerUrl,
34+
clientId: clientId,
35+
}, nil
36+
}
37+
38+
func (tc *tokenCache) getKey() string {
39+
return fmt.Sprintf("%s:%s", tc.providerUrl, tc.clientId)
40+
}
41+
42+
func (tc *tokenCache) getCachedRefreshToken() (string, error) {
43+
if tc == nil {
44+
return "", errCacheNotInitialized
45+
}
46+
47+
key := tc.getKey()
48+
data, err := keyring.Get(keyringServiceName, key)
49+
if err != nil {
50+
if errors.Is(err, keyring.ErrNotFound) {
51+
return "", nil
52+
}
53+
return "", fmt.Errorf("failed to get token from keyring: %w", err)
54+
}
55+
56+
var cached cachedToken
57+
if err := json.Unmarshal([]byte(data), &cached); err != nil {
58+
return "", fmt.Errorf("failed to unmarshal cached token: %w", err)
59+
}
60+
61+
return cached.RefreshToken, nil
62+
}
63+
64+
func (tc *tokenCache) saveRefreshToken(refreshToken string) error {
65+
if tc == nil {
66+
return errCacheNotInitialized
67+
}
68+
69+
if refreshToken == "" {
70+
return errors.New("refresh token is empty")
71+
}
72+
73+
key := tc.getKey()
74+
cached := cachedToken{
75+
RefreshToken: refreshToken,
76+
}
77+
78+
data, err := json.Marshal(cached)
79+
if err != nil {
80+
return fmt.Errorf("failed to marshal token: %w", err)
81+
}
82+
83+
if err := keyring.Set(keyringServiceName, key, string(data)); err != nil {
84+
return fmt.Errorf("failed to save refresh token to keyring: %w", err)
85+
}
86+
87+
return nil
88+
}
89+
90+
func (tc *tokenCache) deleteToken() error {
91+
if tc == nil {
92+
return errCacheNotInitialized
93+
}
94+
95+
if err := keyring.Delete(keyringServiceName, tc.getKey()); err != nil && !errors.Is(err, keyring.ErrNotFound) {
96+
return fmt.Errorf("failed to delete token from keyring: %w", err)
97+
}
98+
return nil
99+
}
100+
101+
// refreshToken exchanges a refresh token for a new access token.
102+
// If the provider returns a new refresh token (rotation), it updates the cache.
103+
func refreshToken(ctx context.Context, config *oauth2.Config, refreshToken string, cache *tokenCache) (*oauth2.Token, error) {
104+
if refreshToken == "" {
105+
return nil, errors.New("no refresh token available")
106+
}
107+
108+
// We construct a token with an expiry in the past to force oauth2.TokenSource
109+
// to perform a refresh. The oauth2 library only refreshes when the token is
110+
// expired; by setting Expiry to 1 hour ago, we guarantee the subsequent
111+
// tokenSource.Token() call will exchange our refresh token for a new access token.
112+
oldToken := &oauth2.Token{
113+
RefreshToken: refreshToken,
114+
TokenType: "Bearer",
115+
Expiry: time.Now().Add(-1 * time.Hour),
116+
}
117+
118+
tokenSource := config.TokenSource(ctx, oldToken)
119+
120+
newToken, err := tokenSource.Token()
121+
if err != nil {
122+
return nil, fmt.Errorf("failed to refresh token: %w", err)
123+
}
124+
125+
if newToken.RefreshToken != "" {
126+
if cache != nil {
127+
if saveErr := cache.saveRefreshToken(newToken.RefreshToken); saveErr != nil {
128+
log.WithError(saveErr).Error("Failed to save refreshed token to cache")
129+
}
130+
}
131+
} else {
132+
newToken.RefreshToken = refreshToken
133+
}
134+
135+
return newToken, nil
136+
}
137+
138+
// tryGetCachedToken attempts to retrieve and refresh a cached token.
139+
// Returns (nil, cache) if no valid cached token exists but caching is available.
140+
func tryGetCachedToken(
141+
ctx context.Context,
142+
config *oauth2.Config,
143+
providerUrl string,
144+
clientId string,
145+
cacheEnabled bool,
146+
) (*oauth2.Token, *tokenCache) {
147+
if !cacheEnabled {
148+
return nil, nil
149+
}
150+
151+
cache, err := newTokenCache(providerUrl, clientId)
152+
if err != nil {
153+
log.Warn("Token cache unavailable, proceeding without caching")
154+
return nil, nil
155+
}
156+
157+
cachedRefreshToken, err := cache.getCachedRefreshToken()
158+
if err != nil || cachedRefreshToken == "" {
159+
return nil, cache
160+
}
161+
162+
newToken, err := refreshToken(ctx, config, cachedRefreshToken, cache)
163+
if err != nil {
164+
_ = cache.deleteToken()
165+
return nil, cache
166+
}
167+
168+
return newToken, cache
169+
}
170+
171+
func saveTokenToCache(token *oauth2.Token, cache *tokenCache) {
172+
if cache == nil || token == nil || token.RefreshToken == "" {
173+
return
174+
}
175+
176+
if err := cache.saveRefreshToken(token.RefreshToken); err != nil {
177+
log.WithError(err).Error("Failed to save token to cache")
178+
}
179+
}

pkg/client/auth/oidc/device.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type DeviceDetails struct {
2222
Scopes []string
2323
}
2424

25-
func AuthenticateDevice(config DeviceDetails) (*TokenCredentials, error) {
25+
func AuthenticateDevice(config DeviceDetails, cacheToken bool) (*TokenCredentials, error) {
2626
ctx := context.Background()
2727

2828
httpClient := http.DefaultClient
@@ -63,6 +63,12 @@ func AuthenticateDevice(config DeviceDetails) (*TokenCredentials, error) {
6363
Scopes: scopes,
6464
}
6565

66+
// Try to use cached refresh token if enabled
67+
token, cache := tryGetCachedToken(ctx, &oauth, config.ProviderUrl, config.ClientId, cacheToken)
68+
if token != nil {
69+
return &TokenCredentials{oauth.TokenSource(ctx, token)}, nil
70+
}
71+
6672
deviceFlowResponse, err := requestDeviceAuthorization(ctx, httpClient, claims.DeviceAuthorizationEndpoint, config.ClientId, scopes)
6773
if err != nil {
6874
return nil, err
@@ -97,6 +103,7 @@ func AuthenticateDevice(config DeviceDetails) (*TokenCredentials, error) {
97103
token, err := requestToken(ctx, httpClient, oauth.Endpoint.TokenURL, config.ClientId, deviceFlowResponse.DeviceCode)
98104
if err == nil {
99105
fmt.Printf("\nAuthentication successful!\n\n")
106+
saveTokenToCache(token, cache)
100107
return &TokenCredentials{oauth.TokenSource(ctx, token)}, nil
101108
}
102109

pkg/client/auth/oidc/password.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type ClientPasswordDetails struct {
1515
Password string
1616
}
1717

18-
func AuthenticateWithPassword(config ClientPasswordDetails) (*TokenCredentials, error) {
18+
func AuthenticateWithPassword(config ClientPasswordDetails, cacheToken bool) (*TokenCredentials, error) {
1919
ctx := context.Background()
2020

2121
provider, err := openId.NewProvider(ctx, config.ProviderUrl)
@@ -34,10 +34,19 @@ func AuthenticateWithPassword(config ClientPasswordDetails) (*TokenCredentials,
3434
return authConfig.PasswordCredentialsToken(ctx, config.Username, config.Password)
3535
},
3636
}
37+
38+
// Try to use cached refresh token if enabled
39+
token, cache := tryGetCachedToken(ctx, authConfig, config.ProviderUrl, config.ClientId, cacheToken)
40+
if token != nil {
41+
return &TokenCredentials{oauth2.ReuseTokenSource(token, source)}, nil
42+
}
43+
3744
t, err := source.Token()
3845
if err != nil {
3946
return nil, err
4047
}
48+
49+
saveTokenToCache(t, cache)
4150
cachedSource := oauth2.ReuseTokenSource(t, source)
4251
return &TokenCredentials{cachedSource}, nil
4352
}

pkg/client/auth/oidc/pkce.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,16 @@ type PKCEDetails struct {
2525
Scopes []string
2626
}
2727

28-
func AuthenticatePkce(config PKCEDetails) (*TokenCredentials, error) {
28+
func AuthenticatePkce(config PKCEDetails, cacheToken bool) (*TokenCredentials, error) {
2929
ctx := context.Background()
3030

31-
result := make(chan *oauth2.Token)
32-
errorResult := make(chan error)
33-
3431
provider, err := openId.NewProvider(ctx, config.ProviderUrl)
3532
if err != nil {
3633
return nil, err
3734
}
3835

39-
localUrl := "localhost:" + strconv.Itoa(int(config.LocalPort))
36+
portStr := strconv.Itoa(int(config.LocalPort))
37+
localUrl := "localhost:" + portStr
4038

4139
oauth := oauth2.Config{
4240
ClientID: config.ClientId,
@@ -45,6 +43,16 @@ func AuthenticatePkce(config PKCEDetails) (*TokenCredentials, error) {
4543
Scopes: append(config.Scopes, openId.ScopeOpenID),
4644
}
4745

46+
// Try to use cached refresh token if enabled
47+
token, cache := tryGetCachedToken(ctx, &oauth, config.ProviderUrl, config.ClientId, cacheToken)
48+
if token != nil {
49+
return &TokenCredentials{oauth.TokenSource(ctx, token)}, nil
50+
}
51+
52+
// Perform interactive authentication if no valid cached token
53+
result := make(chan *oauth2.Token)
54+
errorResult := make(chan error)
55+
4856
state := randomStringBase64() // xss protection
4957
challenge := randomStringBase64()
5058
challengeSum := sha256.Sum256([]byte(challenge))
@@ -104,18 +112,18 @@ func AuthenticatePkce(config PKCEDetails) (*TokenCredentials, error) {
104112
}()
105113

106114
cmd, err := openBrowser("http://" + localUrl)
115+
if err != nil {
116+
return nil, err
117+
}
107118
defer func() {
108119
if err := cmd.Process.Kill(); err != nil {
109120
log.WithStacktrace(err).Error("unable to kill process")
110121
}
111122
}()
112123

113-
if err != nil {
114-
return nil, err
115-
}
116-
117124
select {
118125
case t := <-result:
126+
saveTokenToCache(t, cache)
119127
return &TokenCredentials{oauth.TokenSource(ctx, t)}, nil
120128
case e := <-errorResult:
121129
return nil, e

0 commit comments

Comments
 (0)