Add token rotation
jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.
Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b44b92975
commit
0b2159b1a4
21 files changed
+755
-46
No files matched your search
+10
-10
@@ -536,13 +536,17 @@ func tokenCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(tokens, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tLAST USED\tREVOKED")
|
||||
fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tROTATED\tLAST USED\tREVOKED")
|
||||
for _, t := range tokens {
|
||||
g := "(user's)"
|
||||
if t.Grants != nil {
|
||||
g = strings.Join(t.GrantStrings, "; ")
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtTime(t.ExpiresAt), fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt))
|
||||
rot := fmtTime(t.RotatedAt)
|
||||
if t.PrevExpiresAt != nil && time.Now().Before(*t.PrevExpiresAt) {
|
||||
rot += " (old secret until " + fmtTime(t.PrevExpiresAt) + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtTime(t.ExpiresAt), rot, fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -581,13 +585,9 @@ func tokenCmd() *cobra.Command {
|
||||
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
where := flagConfig
|
||||
if save {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = t.Secret
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
if where, err = saveTokenInConfig(t.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -603,7 +603,7 @@ func tokenCmd() *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt))
|
||||
if save {
|
||||
fmt.Fprintf(w, "Saved to:\t%s\n", flagConfig)
|
||||
fmt.Fprintf(w, "Saved to:\t%s\n", where)
|
||||
} else {
|
||||
fmt.Fprintln(w, "\nThe token is shown only once; store it now.")
|
||||
}
|
||||
@@ -636,7 +636,7 @@ func tokenCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, create, revoke)
|
||||
cmd.AddCommand(list, create, tokenRotateCmd(), revoke)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/client"
|
||||
)
|
||||
|
||||
// tokenRotateCmd is 'jarl token rotate'.
|
||||
func tokenRotateCmd() *cobra.Command {
|
||||
var user, grace, expires, write string
|
||||
var current, noExpiry, save bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "rotate [ID|NAME]",
|
||||
Short: "Give a token a new secret, keeping its name, grants and ID",
|
||||
Long: `Gives a token a new secret. The token keeps its ID, name, user and grants,
|
||||
so nothing has to be set up again: only the programs that use it need the
|
||||
new secret, which is printed once (or written with --write/--save).
|
||||
|
||||
The old secret stops working at once. With --grace it keeps working for a
|
||||
while (at most 7d), so a client can be switched over without an outage;
|
||||
jarld logs every use of the old secret, so you can see what still uses it.
|
||||
To shut out a leaked token, rotate without --grace (or revoke it).
|
||||
|
||||
A token that expires gets its lifetime again, counted from now (a 90-day
|
||||
token is good for another 90 days). --expires sets a new lifetime and
|
||||
--no-expiry removes the expiry.
|
||||
|
||||
The token is given by ID, by name (your own tokens, or --user's), or with
|
||||
--current, the token this command runs with. A restricted token may rotate
|
||||
itself, which lets automation renew its own token.`,
|
||||
Example: ` jarl token rotate 12
|
||||
jarl token rotate laptop --save # and update the CLI's configuration
|
||||
jarl token rotate --current --save --grace 10m
|
||||
jarl token rotate deploy --user ci --grace 1h -q
|
||||
jarl --token @/etc/jarl/certbot.token token rotate --current --write /etc/jarl/certbot.token`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if current == (len(args) == 1) {
|
||||
return fmt.Errorf("give a token ID or name, or --current")
|
||||
}
|
||||
// Check that --save can work before the old secret is gone.
|
||||
var origin tokenOrigin
|
||||
if save {
|
||||
var err error
|
||||
if origin, err = findTokenOrigin(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var id int64
|
||||
if current {
|
||||
var who api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &who); err != nil {
|
||||
return err
|
||||
}
|
||||
if who.Token == nil {
|
||||
return fmt.Errorf("--current: this command isn't using a token")
|
||||
}
|
||||
id = who.Token.ID
|
||||
} else if id, err = tokenID(c, args[0], user); err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.TokenRotation{Grace: grace, ExpiresIn: expires, NoExpiry: noExpiry}
|
||||
var t api.Token
|
||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/tokens/%d/rotate", id), nil, in, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
var saved []string
|
||||
if write != "" {
|
||||
if err := writeTokenFile(write, t.Secret); err != nil {
|
||||
return fmt.Errorf("the token was rotated, but writing %s failed: %w\nnew token: %s", write, err, t.Secret)
|
||||
}
|
||||
saved = append(saved, write)
|
||||
}
|
||||
if save {
|
||||
where, err := origin.store(t.Secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the token was rotated, but saving it failed: %w\nnew token: %s", err, t.Secret)
|
||||
}
|
||||
saved = append(saved, where)
|
||||
}
|
||||
if flagQuiet {
|
||||
if len(saved) == 0 {
|
||||
fmt.Println(t.Secret)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return output(t, func(w io.Writer) {
|
||||
if len(saved) == 0 {
|
||||
fmt.Fprintf(w, "Token:\t%s\n", t.Secret)
|
||||
}
|
||||
fmt.Fprintf(w, "Name:\t%s (id %d, user %s)\n", t.Name, t.ID, t.Username)
|
||||
if t.Grants != nil {
|
||||
fmt.Fprintf(w, "Grants:\t%s\n", strings.Join(t.GrantStrings, "; "))
|
||||
}
|
||||
fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt))
|
||||
if t.PrevExpiresAt != nil {
|
||||
fmt.Fprintf(w, "Old secret:\tworks until %s\n", fmtTime(t.PrevExpiresAt))
|
||||
} else {
|
||||
fmt.Fprintf(w, "Old secret:\tno longer works\n")
|
||||
}
|
||||
if len(saved) > 0 {
|
||||
fmt.Fprintf(w, "Saved to:\t%s\n", strings.Join(saved, ", "))
|
||||
} else {
|
||||
fmt.Fprintln(w, "\nThe new token is shown only once; store it now.")
|
||||
if current && t.PrevExpiresAt == nil {
|
||||
fmt.Fprintln(w, "This was the token this command ran with, so the CLI needs the new one (next time: --save).")
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.BoolVar(¤t, "current", false, "rotate the token this command runs with")
|
||||
f.StringVar(&user, "user", "", "look the NAME up among this user's tokens (admin)")
|
||||
f.StringVar(&grace, "grace", "", "keep the old secret working this long, e.g. 10m, 24h or 2d (at most 7d)")
|
||||
f.StringVar(&expires, "expires", "", "new lifetime from now, e.g. 90d (default: the token's current lifetime again)")
|
||||
f.BoolVar(&noExpiry, "no-expiry", false, "the token no longer expires")
|
||||
f.StringVar(&write, "write", "", "write the new token to this file (mode 0600 if new) instead of printing it")
|
||||
f.BoolVar(&save, "save", false, "store the new token where the CLI gets its token: its configuration file, or the @FILE it reads")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// tokenID finds a token by ID or by name.
|
||||
func tokenID(c *client.Client, arg, user string) (int64, error) {
|
||||
if id, err := strconv.ParseInt(arg, 10, 64); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if user != "" {
|
||||
uid, err := userID(c, user)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
path = fmt.Sprintf("/users/%d/tokens", uid)
|
||||
}
|
||||
var tokens []api.Token
|
||||
if err := c.Get(ctx(), path, nil, &tokens); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var ids []string
|
||||
var found int64
|
||||
for _, t := range tokens {
|
||||
if t.Name == arg && t.RevokedAt == nil {
|
||||
found = t.ID
|
||||
ids = append(ids, strconv.FormatInt(t.ID, 10))
|
||||
}
|
||||
}
|
||||
switch len(ids) {
|
||||
case 0:
|
||||
if user == "" {
|
||||
return 0, fmt.Errorf("you have no token named %q (another user's: --user)", arg)
|
||||
}
|
||||
return 0, fmt.Errorf("user %s has no token named %q", user, arg)
|
||||
case 1:
|
||||
return found, nil
|
||||
}
|
||||
return 0, fmt.Errorf("several tokens are named %q (IDs %s); give the ID", arg, strings.Join(ids, ", "))
|
||||
}
|
||||
|
||||
// tokenOrigin is where the CLI's token comes from, for --save.
|
||||
type tokenOrigin struct {
|
||||
file string // the token is read from this file (@FILE)
|
||||
server string // else it's in the configuration file: this server's entry...
|
||||
top bool // ...or the top-level token
|
||||
}
|
||||
|
||||
// findTokenOrigin follows the same rules as newClient.
|
||||
func findTokenOrigin() (tokenOrigin, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return tokenOrigin{}, err
|
||||
}
|
||||
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||
if err != nil {
|
||||
return tokenOrigin{}, err
|
||||
}
|
||||
if len(ts) != 1 {
|
||||
return tokenOrigin{}, fmt.Errorf("--save needs a single server")
|
||||
}
|
||||
t := ts[0]
|
||||
var o tokenOrigin
|
||||
var token string
|
||||
switch {
|
||||
case t.Name != "" && t.Token != "":
|
||||
token, o.server = t.Token, t.Name
|
||||
case flagToken != "":
|
||||
token = flagToken
|
||||
case os.Getenv("JARL_TOKEN") != "":
|
||||
token = os.Getenv("JARL_TOKEN")
|
||||
case t.Name != "":
|
||||
token, o.server = t.Token, t.Name
|
||||
default:
|
||||
token, o.top = cfg.Token, true
|
||||
}
|
||||
if f, ok := strings.CutPrefix(token, "@"); ok {
|
||||
return tokenOrigin{file: f}, nil
|
||||
}
|
||||
if !o.top && o.server == "" {
|
||||
return tokenOrigin{}, fmt.Errorf("--save: the token comes from --token or JARL_TOKEN, so there's nowhere to save it; use --write FILE (or put the token in a file and pass --token @FILE)")
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// store saves a new token where the old one came from, and says where.
|
||||
func (o tokenOrigin) store(secret string) (string, error) {
|
||||
if o.file != "" {
|
||||
return o.file, writeTokenFile(o.file, secret)
|
||||
}
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if o.server != "" {
|
||||
e := cfg.Servers[o.server]
|
||||
if e == nil {
|
||||
return "", fmt.Errorf("server %s is no longer in %s", o.server, flagConfig)
|
||||
}
|
||||
e.Token = secret
|
||||
} else {
|
||||
cfg.Token = secret
|
||||
}
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if o.server != "" {
|
||||
return flagConfig + " (server " + o.server + ")", nil
|
||||
}
|
||||
return flagConfig, nil
|
||||
}
|
||||
|
||||
// saveTokenInConfig stores a token in the CLI configuration file, for the
|
||||
// selected server (its named entry, or the top-level token).
|
||||
func saveTokenInConfig(secret string) (string, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ts) == 1 && ts[0].Name != "" {
|
||||
if e := cfg.Servers[ts[0].Name]; e != nil {
|
||||
e.Token = secret
|
||||
return flagConfig + " (server " + ts[0].Name + ")", saveCLIConfig(cfg)
|
||||
}
|
||||
}
|
||||
cfg.Token = secret
|
||||
return flagConfig, saveCLIConfig(cfg)
|
||||
}
|
||||
|
||||
// writeTokenFile replaces a token file's content. An existing file keeps
|
||||
// its owner and mode; a new one is created with mode 0600.
|
||||
func writeTokenFile(path, secret string) error {
|
||||
return os.WriteFile(path, []byte(secret+"\n"), 0o600)
|
||||
}
|
||||
Reference in new issue
Block a user