-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtoken_cmd.go
More file actions
296 lines (273 loc) · 7.06 KB
/
token_cmd.go
File metadata and controls
296 lines (273 loc) · 7.06 KB
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strings"
"sync"
"time"
retry "github.com/appleboy/go-httpretry"
"github.com/go-authgate/sdk-go/credstore"
"github.com/spf13/cobra"
)
type tokenGetOutput struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresAt time.Time `json:"expires_at"`
Expired bool `json:"expired"`
ClientID string `json:"client_id"`
// RefreshToken is intentionally omitted — it is a long-lived secret
// that should not be casually printed to stdout or captured in logs.
}
func buildTokenCmd() *cobra.Command {
tokenCmd := &cobra.Command{
Use: "token",
Short: "Manage stored tokens",
}
tokenCmd.AddCommand(buildTokenGetCmd())
tokenCmd.AddCommand(buildTokenDeleteCmd())
return tokenCmd
}
func buildTokenGetCmd() *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: "get",
Short: "Print the stored access token",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadStoreConfig()
if code := runTokenGet(
cfg.Store,
cfg.ClientID,
jsonOutput,
cmd.OutOrStdout(),
cmd.ErrOrStderr(),
); code != 0 {
return exitCodeError(code)
}
return nil
},
}
cmd.Flags().
BoolVar(&jsonOutput, "json", false, "Output token details as JSON (access_token, token_type, expires_at, expired, client_id)")
return cmd
}
func buildTokenDeleteCmd() *cobra.Command {
var localOnly bool
cmd := &cobra.Command{
Use: "delete",
Short: "Delete the stored token",
Long: `Delete the stored token.
By default, the token is first revoked on the OAuth server before being
deleted locally. If the server is unreachable, the local token is still
deleted (graceful degradation).
Use --local-only to skip server revocation and only delete the local token.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
var cfg *AppConfig
if localOnly {
cfg = loadStoreConfig()
} else {
cfg = loadConfig()
resolveEndpoints(cmd.Context(), cfg)
}
if code := runTokenDelete(
cmd.Context(),
cfg,
localOnly,
cmd.OutOrStdout(),
cmd.ErrOrStderr(),
); code != 0 {
return exitCodeError(code)
}
return nil
},
}
cmd.Flags().
BoolVar(&localOnly, "local-only", false, "Skip server-side token revocation; only delete the local token")
return cmd
}
// loadTokenOrFail loads the token for id and writes user-facing diagnostics
// to stderr on failure. Returns the token and 0, or a zero token and a
// non-zero exit code.
func loadTokenOrFail(
store credstore.Store[credstore.Token],
id string,
stderr io.Writer,
) (credstore.Token, int) {
tok, err := store.Load(id)
if err != nil {
if errors.Is(err, credstore.ErrNotFound) {
fmt.Fprintf(stderr, "Error: no stored token for client-id %q\n", id)
fmt.Fprintf(stderr, "Hint: run 'authgate-cli' first to authenticate.\n")
return tok, 1
}
fmt.Fprintf(stderr, "Error: failed to load token: %v\n", err)
return tok, 1
}
return tok, 0
}
// runTokenDelete is the testable core of `token delete`.
func runTokenDelete(
ctx context.Context,
cfg *AppConfig,
localOnly bool,
stdout io.Writer,
stderr io.Writer,
) int {
// Check existence first — Delete is idempotent and silently succeeds
// even when the key is absent.
tok, code := loadTokenOrFail(cfg.Store, cfg.ClientID, stderr)
if code != 0 {
return code
}
if !localOnly {
if err := revokeTokenOnServer(ctx, cfg, tok, stderr); err != nil {
fmt.Fprintf(stderr, "Warning: server-side revocation failed: %v\n", err)
fmt.Fprintln(stderr, "Proceeding with local token deletion.")
} else {
fmt.Fprintln(stdout, "Token revoked on server.")
}
}
if err := cfg.Store.Delete(cfg.ClientID); err != nil {
fmt.Fprintf(stderr, "Error: failed to delete token: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "Token for client-id %q deleted.\n", cfg.ClientID)
return 0
}
// revokeTokenOnServer attempts to revoke tokens on the OAuth server (RFC 7009).
// It revokes the refresh and access tokens concurrently.
func revokeTokenOnServer(
ctx context.Context,
cfg *AppConfig,
tok credstore.Token,
stderr io.Writer,
) error {
revokeURL := cfg.Endpoints.RevocationURL
timeout := cfg.RevocationTimeout
var (
mu sync.Mutex
refreshErr error
accessErr error
wg sync.WaitGroup
)
if tok.RefreshToken != "" {
wg.Go(func() {
if err := doRevoke(
ctx,
cfg,
revokeURL,
tok.RefreshToken,
"refresh_token",
timeout,
); err != nil {
mu.Lock()
refreshErr = err
mu.Unlock()
}
})
}
if tok.AccessToken != "" {
wg.Go(func() {
if err := doRevoke(
ctx,
cfg,
revokeURL,
tok.AccessToken,
"access_token",
timeout,
); err != nil {
mu.Lock()
accessErr = err
mu.Unlock()
}
})
}
wg.Wait()
switch {
case accessErr != nil && refreshErr != nil:
fmt.Fprintf(stderr, "Warning: failed to revoke refresh token: %v\n", refreshErr)
return fmt.Errorf("access token revocation: %w", accessErr)
case accessErr != nil:
return fmt.Errorf("access token revocation: %w", accessErr)
case refreshErr != nil:
return fmt.Errorf("refresh token revocation: %w", refreshErr)
default:
return nil
}
}
// doRevoke posts a single token to the revocation endpoint (RFC 7009).
// It includes client_id (and client_secret for confidential clients) as
// required by most OAuth servers for client authentication.
func doRevoke(
ctx context.Context,
cfg *AppConfig,
revokeURL string,
token string,
tokenTypeHint string,
timeout time.Duration,
) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
data := url.Values{
"token": {token},
"client_id": {cfg.ClientID},
}
if tokenTypeHint != "" {
data.Set("token_type_hint", tokenTypeHint)
}
if !cfg.IsPublicClient() {
data.Set("client_secret", cfg.ClientSecret)
}
resp, err := cfg.RetryClient.Post(ctx, revokeURL,
retry.WithBody(
"application/x-www-form-urlencoded",
strings.NewReader(data.Encode()),
),
)
if err != nil {
return fmt.Errorf("revoke request: %w", err)
}
defer resp.Body.Close()
// Drain a bounded amount of the body for proper HTTP connection reuse.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("revoke returned status %d", resp.StatusCode)
}
return nil
}
// runTokenGet is the testable core of `token get`.
func runTokenGet(
store credstore.Store[credstore.Token],
id string,
jsonOut bool,
stdout io.Writer,
stderr io.Writer,
) int {
tok, code := loadTokenOrFail(store, id, stderr)
if code != 0 {
return code
}
if jsonOut {
out := tokenGetOutput{
AccessToken: tok.AccessToken,
TokenType: tok.TokenType,
ExpiresAt: tok.ExpiresAt,
Expired: time.Now().After(tok.ExpiresAt),
ClientID: tok.ClientID,
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintf(stderr, "Error: failed to write output: %v\n", err)
return 1
}
return 0
}
fmt.Fprintln(stdout, tok.AccessToken)
return 0
}