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:
abrown38andClaude Opus 5.5 committed 2026-10-08 13:58:50 +00:00
1 parent 6b44b92975
commit 0b2159b1a4
21 files changed
+755 -46

No files matched your search

+14
View File
@@ -24,6 +24,15 @@ The first version, to be released as 0.1.0.
the web UI. Networks smaller than a /24 explain that they belong in the
enclosing /24's zone.
- docs/operations.md: setting up a secondary server with its own jarld.
- Token rotation: `jarl token rotate`, the 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 (`--grace`), 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.
`jarl token list` shows when a token was last rotated.
- An endpoint for lego's `httpreq` DNS provider
(`/api/v1/acme/httpreq/present` and `/cleanup`), so lego and Traefik can
publish DNS-01 challenges with only environment variables, without the
@@ -183,6 +192,11 @@ The first version, to be released as 0.1.0.
### Fixed
- `jarl token create --save` stored the token at the top level of the
CLI configuration even when a named server was selected; it now goes into
that server's entry.
- The web UI showed future times, such as token expiry dates, as "just
now"; they now read "in 29 days".
- The dashboard's "Pending changes (1 open)" counted empty changesets (and
other users'), so the Changes page then showed none. It now counts your
own changesets that contain changes, and the status API's
+4 -2
View File
@@ -37,8 +37,10 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
diff, and apply them together. Every applied changeset can be reverted.
Serials are bumped automatically.
* **Users and API tokens with granular permissions**, e.g. a token that may
only write `_acme-challenge` TXT records in one zone. Passwords use
argon2id, sessions have CSRF protection, logins are rate limited.
only write `_acme-challenge` TXT records in one zone. Tokens can be
rotated (a new secret, same grants), with an optional grace period.
Passwords use argon2id, sessions have CSRF protection, logins are rate
limited.
* **Audit log** of every login, change and token, with user, token, IP and
outcome.
* **Query statistics** (optional, off by default): queries over time,
+10 -10
View File
@@ -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
}
+268
View File
@@ -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(&current, "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)
}
+1
View File
@@ -315,6 +315,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
| DELETE | `/tokens/{id}` | revoke |
| POST | `/tokens/{id}/rotate` | New secret, same token. Optional body `{"grace": "1h", "expires_in": "90d", "expires_at": "...", "no_expiry": false}`. The response's `token` field is the new secret, shown once; `previous_secret_valid_until` says when the old one stops. A restricted token may rotate itself and tokens no wider than itself |
| POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) |
User and token changes take effect immediately. They are not changesets,
+3 -1
View File
@@ -272,6 +272,8 @@ jarl token list [--user alice | --all] [--revoked]
jarl token create laptop --expires 365d
jarl token create backup-script --grant "*:read"
jarl token create acme --user certbot --role acme
jarl token rotate laptop --save # new secret, same token and grants
jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7
jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
@@ -281,7 +283,7 @@ jarl check # validation + named-checkconf of the current configuration
jarl resync [--force] [--dry-run] # rewrite every file from the database
```
Grant syntax and roles are in [permissions.md](permissions.md).
Grant syntax, roles and token rotation are in [permissions.md](permissions.md).
## Query statistics
+48
View File
@@ -113,6 +113,54 @@ jarl token revoke 12
In the web UI: Account → API tokens (your own) and Users → *user* →
Tokens (admin).
### Rotating a token
Rotating gives a token a **new secret** and changes nothing else: it keeps
its ID, name, user, grants and audit history, so there is nothing to set
up again. Only the programs that use the token need the new secret. Use it
on a schedule, when someone who knew the token leaves, or when it may have
leaked. Revoking is for a token that is no longer needed.
```sh
jarl token rotate 12 # by ID (see 'jarl token list')
jarl token rotate laptop --save # by name, and update the CLI's configuration
jarl token rotate deploy --user ci --grace 1h # another user's token (admin), old secret works 1 more hour
jarl token rotate --current --save # the token this CLI runs with
```
* **The old secret** stops working at once. With `--grace` (at most 7d)
it keeps working for that long, so clients can be switched over without
an outage. While it does, jarld logs a warning each time the old secret
is used (at most once a minute per token and address), with the client's
IP, so you can find what still needs updating:
`journalctl -u jarl | grep "old secret"`. `jarl token list` and the web
UI show when the old secret stops. Rotating again ends the previous grace
period. If the token may have leaked, rotate without `--grace`.
* **Expiry**: a token that expires gets its lifetime again, counted from
now, so a 90-day token is good for another 90 days. `--expires 30d` sets
a new lifetime and `--no-expiry` removes the expiry. A token without an
expiry keeps having none.
* **Where the new secret goes**: it is printed once, like a new token.
`--write FILE` writes it to a file instead (an existing file keeps its
owner and mode; a new one gets mode 0600). `--save` puts it where the CLI
got its token from: the configuration file (the selected server's entry)
or the file named in `--token @FILE`. `--save` refuses before rotating
when the token came from a literal `--token` or `JARL_TOKEN`.
* **Who may rotate**: administrators any token; users their own tokens. A
restricted token may rotate itself and tokens no wider than itself, but
not a wider token of its user, since it would learn that token's secret.
So automation can renew its own token, for example a monthly timer:
```sh
jarl --token @/etc/jarl/certbot.token token rotate --current --save
```
* Every rotation is in the audit log (`token.rotate`), with when the old
secret stops and the new expiry.
In the web UI, the **Rotate** button next to each token asks how long the
old secret should keep working and how long the token should last, then
shows the new secret once.
## Recovering access
On the server, as root or the `jarl` user:
+14
View File
@@ -191,6 +191,20 @@ type TokenInput struct {
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// TokenRotation gives a token a new secret (POST /tokens/{id}/rotate).
// Every field is optional.
type TokenRotation struct {
// Grace keeps the old secret working this long (e.g. "1h", "2d"; at
// most 7d), so clients can be updated without an outage. Default:
// the old secret stops at once.
Grace string `json:"grace,omitempty"`
// The new expiry. Default: a token that expires gets its lifetime
// again, counted from now; one that doesn't, still doesn't.
ExpiresIn string `json:"expires_in,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
NoExpiry bool `json:"no_expiry,omitempty"`
}
// ZoneImport imports zone file text into a zone.
type ZoneImport struct {
Text string `json:"text"`
+7
View File
@@ -105,6 +105,13 @@ func NewToken() (token, publicID, secretHash string) {
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
}
// RotateToken makes a new token with an existing public ID (a rotated
// token keeps its ID; only the secret changes).
func RotateToken(publicID string) (token, secretHash string) {
secret := randomString(20)
return TokenPrefix + publicID + "_" + secret, HashSecret(secret)
}
// ParseToken splits a token into its public ID and secret.
func ParseToken(token string) (publicID, secret string, err error) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
+14
View File
@@ -89,6 +89,20 @@ type Token struct {
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
// RotatedAt is when the token last got a new secret (nil: never).
RotatedAt *time.Time `json:"rotated_at,omitempty"`
// After a rotation with a grace period, the previous secret keeps
// working until PrevExpiresAt.
PrevSecretHash string `json:"-"`
PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"`
}
// IssuedAt is when the token's current secret was made.
func (t *Token) IssuedAt() time.Time {
if t.RotatedAt != nil {
return *t.RotatedAt
}
return t.CreatedAt
}
// Session is a logged-in web session.
+41 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/subtle"
"errors"
"fmt"
"net/http"
"strings"
"sync"
@@ -78,6 +79,31 @@ func (l *rateLimiter) cleanup() {
}
}
// throttle allows an event once a minute per key (for log lines that
// could otherwise repeat on every request).
type throttle struct {
mu sync.Mutex
last map[string]time.Time
}
func (t *throttle) allow(key string, now time.Time) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.last == nil {
t.last = map[string]time.Time{}
}
if now.Sub(t.last[key]) < time.Minute {
return false
}
for k, v := range t.last {
if now.Sub(v) > time.Hour {
delete(t.last, k)
}
}
t.last[key] = now
return true
}
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"}
// authenticate identifies the caller from a bearer token or a session
@@ -143,10 +169,19 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
if err != nil {
return nil, err
}
if !auth.CheckSecret(secret, t.SecretHash) {
return nil, errors.New("wrong token secret")
}
now := time.Now()
previous := false
if !auth.CheckSecret(secret, t.SecretHash) {
// After a rotation with a grace period the old secret works until
// the grace period ends.
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
return nil, errors.New("wrong token secret")
}
if now.After(*t.PrevExpiresAt) {
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
}
previous = true
}
if t.RevokedAt != nil {
return nil, errors.New("token was revoked")
}
@@ -163,6 +198,9 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
s.store.TouchToken(ctx, t.ID, ip)
}
if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) {
s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
}
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
}
+123 -14
View File
@@ -1,7 +1,10 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@@ -106,25 +109,28 @@ func parseExpiry(in api.TokenInput) (*time.Time, error) {
if in.ExpiresIn == "" {
return nil, nil
}
v := strings.TrimSpace(in.ExpiresIn)
var d time.Duration
if n, ok := strings.CutSuffix(v, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days <= 0 {
return nil, badRequest("invalid expires_in %q", v)
}
d = time.Duration(days) * 24 * time.Hour
} else {
var err error
d, err = time.ParseDuration(v)
if err != nil || d <= 0 {
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", v)
}
d, err := parseDays(in.ExpiresIn)
if err != nil || d <= 0 {
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
}
t := time.Now().Add(d)
return &t, nil
}
// parseDays parses a duration in Go syntax ("720h", "15m") or in days
// ("90d").
func parseDays(v string) (time.Duration, error) {
v = strings.TrimSpace(v)
if n, ok := strings.CutSuffix(v, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days < 0 {
return 0, fmt.Errorf("invalid duration %q", v)
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(v)
}
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
var in api.TokenInput
if err := decodeJSON(r, &in); err != nil {
@@ -243,6 +249,109 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
return nil
}
// maxRotationGrace is the longest an old secret may keep working.
const maxRotationGrace = 7 * 24 * time.Hour
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. The
// token keeps its ID, name, user and grants, so nothing needs to be set up
// again; only the clients that use it need the new secret. The old secret
// stops working at once, or after the grace period. A token that expires
// gets the same lifetime again, counted from now, unless expires_in or
// expires_at says otherwise.
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.TokenRotation
body, err := readBody(r)
if err != nil {
return err
}
if body != nil {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return badRequest("invalid JSON: %v", err)
}
}
t, err := s.store.GetToken(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return notFound("token %d not found", id)
}
return err
}
if t.UserID != p.User.ID && !p.IsAdmin() {
return notFound("token %d not found", id)
}
// A restricted token may rotate itself, and tokens no wider than
// itself, but not a wider token of the same user: it would learn that
// token's new secret.
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !narrower(t.Grants, p.Token.Grants)) {
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
}
if t.RevokedAt != nil {
return conflict("token %d is revoked; create a new one", id)
}
now := time.Now()
var exp *time.Time
switch {
case in.ExpiresIn != "" || in.ExpiresAt != nil:
if in.NoExpiry {
return badRequest("no_expiry can't be combined with expires_in or expires_at")
}
if exp, err = parseExpiry(api.TokenInput{ExpiresIn: in.ExpiresIn, ExpiresAt: in.ExpiresAt}); err != nil {
return err
}
case in.NoExpiry:
case t.ExpiresAt != nil:
life := t.ExpiresAt.Sub(t.IssuedAt())
if life <= 0 {
return badRequest("can't tell the token's lifetime; give expires_in")
}
e := now.Add(life)
exp = &e
}
var prevUntil *time.Time
if in.Grace != "" {
g, err := parseDays(in.Grace)
if err != nil || g < 0 || g > maxRotationGrace {
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
}
until := now.Add(g)
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
until = *t.ExpiresAt // the old secret never outlives its token
}
if g > 0 && until.After(now) {
prevUntil = &until
}
}
secret, hash := auth.RotateToken(t.PublicID)
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
if errors.Is(err, store.ErrNotFound) {
return conflict("token %d is revoked; create a new one", id)
}
return err
}
t, err = s.store.GetToken(r.Context(), id)
if err != nil {
return err
}
detail := "old secret stops working now"
if prevUntil != nil {
detail = "old secret works until " + prevUntil.UTC().Format(time.RFC3339)
}
if t.ExpiresAt != nil {
detail += "; expires " + t.ExpiresAt.UTC().Format(time.RFC3339)
}
s.eng.Audit(r.Context(), p, "token.rotate", "token", t.ID, t.Username+"/"+t.Name, "success", detail)
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
+1
View File
@@ -26,6 +26,7 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
// Users (admin)
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
+2
View File
@@ -53,6 +53,8 @@ type Server struct {
web fs.FS
certs *certReloader
statsQ *stats.Querier
// oldSecretWarn limits warnings about rotated tokens' old secrets.
oldSecretWarn throttle
mu sync.Mutex
srvs []*http.Server
+98
View File
@@ -0,0 +1,98 @@
package server
import (
"context"
"strings"
"testing"
"time"
)
func TestTokenRotation(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/users", e.token, map[string]any{"username": "ci", "password": "", "grant_strings": []string{"records:write zones=example.com"}}, 201)
var ciID int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "ci" {
ciID = int64(u["id"].(float64))
}
}
created := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "deploy", "grant_strings": []string{"records:write zones=example.com types=TXT"}, "expires_in": "30d"}, 201)
id := itoa(int64(created["id"].(float64)))
old := created["token"].(string)
works := func(tok string) bool {
_, status := e.req("GET", "/auth/whoami", tok, nil)
return status == 200
}
// Rotation keeps the token and its grants; the old secret stops.
rot := e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
tok1 := rot["token"].(string)
if tok1 == old || rot["id"] != created["id"] || rot["name"] != "deploy" || rot["public_id"] != created["public_id"] || rot["rotated_at"] == nil {
t.Errorf("rotated = %v", rot)
}
if g := rot["grant_strings"].([]any); len(g) != 1 || g[0] != "records:write zones=example.com types=TXT" {
t.Errorf("grants after rotation = %v", g)
}
if !works(tok1) || works(old) {
t.Errorf("after rotation: new works %v, old works %v", works(tok1), works(old))
}
// The 30-day lifetime starts again.
exp, _ := time.Parse(time.RFC3339Nano, rot["expires_at"].(string))
if d := time.Until(exp); d < 29*24*time.Hour || d > 31*24*time.Hour {
t.Errorf("expires in %v, want about 30 days", d)
}
// With a grace period both secrets work for a while.
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
tok2 := rot["token"].(string)
if !works(tok2) || !works(tok1) || rot["previous_secret_valid_until"] == nil {
t.Errorf("grace: new %v, old %v, %v", works(tok2), works(tok1), rot)
}
// When the grace period ends, the old secret stops.
e.st.DB().ExecContext(context.Background(), `UPDATE tokens SET prev_expires_at = ? WHERE id = ?`, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), id)
if b, status := e.req("GET", "/auth/whoami", tok1, nil); status != 401 || !strings.Contains(string(b), "grace period has ended") {
t.Errorf("old secret after grace: %d %s", status, b)
}
// Rotating again without grace drops the previous secret at once.
e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
tok3 := rot["token"].(string)
if works(tok2) || !works(tok3) || rot["previous_secret_valid_until"] != nil {
t.Errorf("rotation without grace kept an old secret: %v", rot)
}
// A restricted token may rotate itself...
self := e.do("POST", "/tokens/"+id+"/rotate", tok3, map[string]any{"expires_in": "2d"}, 200)
tok4 := self["token"].(string)
if works(tok3) || !works(tok4) {
t.Error("self-rotation didn't replace the secret")
}
// ...but not a wider token of its user, whose secret it would learn.
wide := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "wide"}, 201)
wideID := itoa(int64(wide["id"].(float64)))
if b, status := e.req("POST", "/tokens/"+wideID+"/rotate", tok4, nil); status != 403 {
t.Errorf("restricted token rotated a wider one: %d %s", status, b)
}
// The unrestricted token can rotate the narrower one.
e.do("POST", "/tokens/"+id+"/rotate", wide["token"].(string), map[string]any{"no_expiry": true}, 200)
// Nobody else's tokens.
if _, status := e.req("POST", "/tokens/1/rotate", wide["token"].(string), nil); status != 404 {
t.Errorf("rotated the admin's token: %d", status)
}
for _, c := range []struct {
body map[string]any
code int
}{
{map[string]any{"grace": "8d"}, 400},
{map[string]any{"grace": "soon"}, 400},
{map[string]any{"expires_in": "1d", "no_expiry": true}, 400},
} {
if b, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, c.body); status != c.code {
t.Errorf("%v: %d %s", c.body, status, b)
}
}
e.do("DELETE", "/tokens/"+id, e.token, nil, 204)
if _, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, nil); status != 409 {
t.Errorf("rotated a revoked token: %d", status)
}
}
+7
View File
@@ -213,6 +213,13 @@ CREATE TABLE deployed_files (
written_at TEXT NOT NULL,
changeset_id INTEGER NOT NULL DEFAULT 0
);
`,
// 2: token rotation. A rotated token gets a new secret; the previous
// one may stay valid for a grace period.
`
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
`,
}
+23 -3
View File
@@ -139,13 +139,13 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
return nil
}
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at`
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at`
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
t := &model.Token{}
var grants, exp, lu, rev sql.NullString
var grants, exp, lu, rev, rot, pexp sql.NullString
var ca string
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil {
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil {
return nil, err
}
if grants.Valid {
@@ -157,6 +157,7 @@ func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
}
}
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp)
t.CreatedAt = parseTS(ca)
return t, nil
}
@@ -235,6 +236,25 @@ func (s *Store) RevokeToken(ctx context.Context, id int64) error {
return nil
}
// RotateToken gives a token a new secret and expiry. With prevUntil set,
// the old secret keeps working until then; otherwise it stops at once. A
// revoked token can't be rotated (ErrNotFound).
func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error {
prevHash := `''`
if prevUntil != nil {
prevHash = `secret_hash`
}
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`,
tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchToken records a token's use. It is best-effort and cheap.
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
+57 -8
View File
@@ -1,4 +1,4 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, copyBox, badge } from "../ui.js";
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, copyBox, badge } from "../ui.js";
import { api, get, state } from "../api.js";
import { rerender } from "../app.js";
@@ -101,20 +101,69 @@ export async function tokensDialog(u, grants, own) {
export function tokenTable(tokens, redraw) {
return table([
{ label: "Name", render: (t) => h("strong", null, t.name) },
{ label: "Name", render: (t) => [h("strong", null, t.name), rotationNote(t)] },
{ label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" },
{ label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") },
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") },
{ label: "", render: (t) => h("button", { class: "small danger", onclick: async () => {
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
await api("DELETE", "/tokens/" + t.id);
toast("Token revoked");
redraw();
} }, "Revoke"), tdClass: "actions-cell" },
{ label: "", render: (t) => [
h("button", { class: "small", onclick: () => rotateDialog(t, redraw) }, "Rotate"), " ",
h("button", { class: "small danger", onclick: async () => {
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
await api("DELETE", "/tokens/" + t.id);
toast("Token revoked");
redraw();
} }, "Revoke")], tdClass: "actions-cell nowrap" },
], tokens, { empty: "No tokens." });
}
// rotationNote says when a token last got a new secret, and whether its
// old secret still works.
function rotationNote(t) {
const parts = [];
if (t.previous_secret_valid_until && new Date(t.previous_secret_valid_until) > new Date()) {
parts.push(h("div", { class: "small warn-text" }, "old secret stops ", time(t.previous_secret_valid_until)));
} else if (t.rotated_at) {
parts.push(h("div", { class: "muted small" }, "rotated ", time(t.rotated_at)));
}
return parts;
}
// rotateDialog gives a token a new secret. The token keeps its name,
// grants and ID; only the programs using it need the new secret.
async function rotateDialog(t, redraw) {
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
if (t.expires_at) expOpts.push(["never", "never expires"]);
const exp = select(expOpts, "");
const err = h("div");
const box = h("div");
const form = h("div", null,
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
h("div", { class: "form-grid" },
field("Old secret", grace, "A grace period lets you update clients without an outage. If the token may have leaked, let it stop now."),
field("Expires", exp)),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async (e) => {
mount(err);
const body = {};
if (grace.value) body.grace = grace.value;
if (exp.value === "never") body.no_expiry = true;
else if (exp.value) body.expires_in = exp.value;
e.target.disabled = true;
try {
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
mount(box, h("div", { class: "msg ok" },
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
copyBox(r.token),
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), " (", time(r.previous_secret_valid_until), ")."] : "The old secret no longer works.")));
if (redraw) redraw();
} catch (ex) { e.target.disabled = false; mount(err, errorBox(ex)); }
} }, "Rotate")));
mount(box, form);
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
export function newTokenForm(base, grants, redraw) {
const err = h("div");
const out = h("div");
+1
View File
@@ -78,6 +78,7 @@ h3 { font-size: 14px; margin: 14px 0 6px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.nowrap { white-space: nowrap; }
.warn-text { color: var(--warn); }
.right { text-align: right; }
.hidden { display: none !important; }
+8 -5
View File
@@ -49,12 +49,15 @@ export function fmtTime(iso) {
export function relTime(iso) {
if (!iso) return "";
const s = (Date.now() - new Date(iso).getTime()) / 1000;
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
const s = Math.abs(diff);
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + " min ago";
if (s < 86400) return Math.floor(s / 3600) + " h ago";
if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago";
return fmtTime(iso);
let n;
if (s < 3600) n = Math.floor(s / 60) + " min";
else if (s < 86400) n = Math.floor(s / 3600) + " h";
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
else return fmtTime(iso);
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
}
export function time(iso) {
+11
View File
@@ -241,6 +241,17 @@ run "acme unset when nothing there" 0 $J $ACME acme unset adbraun.test
run "acme in unknown zone" 1 $J $ACME acme set example.org v
run "token list --all" 0 $J token list --all
contains "certbot-hook"
cp $E/acme.token $E/acme.token.old
run "acme token rotates itself (grace)" 0 $J $ACME token rotate --current --save --grace 1h
run "rotated token works" 0 $J $ACME whoami
run "old secret works during grace" 0 $J --token @$E/acme.token.old whoami
run "rotate again without grace" 0 $J $ACME token rotate --current --save
run "old secret refused after rotation (exit 3)" 3 $J --token @$E/acme.token.old whoami
run "rotated token keeps its grants" 0 $J $ACME acme set adbraun.test rotated
run "acme unset after rotation" 0 $J $ACME acme unset adbraun.test rotated
run "acme token cannot rotate the admin's token (exit 4)" 4 $J $ACME token rotate 1
run "token list shows rotation" 0 $J token list --user certbot
contains "certbot-hook"
TID=$($J token list --user certbot -o json | grep -o '"id": [0-9]*' | head -1 | grep -o '[0-9]*')
run "token revoke" 0 $J token revoke $TID
run "revoked token refused (exit 3)" 3 $J $ACME whoami