Files
pages/internal/auth/bootstrap.go
T
2026-08-15 07:13:00 +00:00

111 lines
3.7 KiB
Go

package auth
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/iceBear67/simplepages/internal/store"
)
// BootstrapKeyName is the name given to the first-run admin key, so an operator
// listing keys can tell it apart from ones they minted themselves.
const BootstrapKeyName = "bootstrap"
// EnsureAdminKey mints an admin key and writes its token to tokenPath when the
// server has no usable admin key at all, and reports whether it did.
//
// This is the only place the server ever writes a token to disk, and it exists
// because a fresh install would otherwise have no way to authenticate the call
// that creates the first key. The file is 0600 and the log line tells the
// operator to delete it once they have copied the token out.
//
// "Usable" excludes revoked and expired keys, so an installation whose only
// admin key was revoked recovers by restarting rather than by hand-editing the
// database.
func EnsureAdminKey(ctx context.Context, db *store.DB, tokenPath string, log *slog.Logger) (bool, error) {
n, err := db.CountUsableAdminKeys(ctx)
if err != nil {
return false, fmt.Errorf("count admin keys: %w", err)
}
if n > 0 {
// Nothing to mint. If a token file is still lying around from an earlier
// bootstrap, say so: it is a live credential until that key is revoked.
if _, err := os.Lstat(tokenPath); err == nil && log != nil {
log.Warn("bootstrap token file still present; delete it once the token is stored elsewhere",
"path", tokenPath)
}
return false, nil
}
token, keyID, hash, err := Mint()
if err != nil {
return false, err
}
if err := writeTokenFile(tokenPath, token); err != nil {
return false, err
}
k := &store.APIKey{
ID: keyID,
SecretHash: hash[:],
Scope: store.ScopeAdmin,
Name: BootstrapKeyName,
}
if err := db.CreateKey(ctx, k); err != nil {
// The file names a key that does not exist. Remove it rather than leave
// an operator holding a token that will never authenticate.
_ = os.Remove(tokenPath)
return false, fmt.Errorf("create bootstrap key: %w", err)
}
if log != nil {
// The key id is the public half of the token and is safe to log; the
// token itself is not, which is the whole reason for the file.
log.Warn("no admin key found; minted a bootstrap admin key",
"key_id", keyID, "path", tokenPath,
"action", "read the token, then delete this file")
}
return true, nil
}
// writeTokenFile writes the token with 0600 permissions.
//
// It writes to a temporary file in the same directory and renames it into
// place. That is not for atomicity — nothing reads this concurrently — but
// because rename replaces the destination without following it. Opening the
// path directly would follow a symlink someone had planted there and write a
// live credential wherever it pointed.
func writeTokenFile(path, token string) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".bootstrap-token-*")
if err != nil {
return fmt.Errorf("create bootstrap token file: %w", err)
}
tmp := f.Name()
defer func() {
f.Close()
os.Remove(tmp) // no-op once the rename has succeeded
}()
// CreateTemp already makes the file 0600, but say so explicitly: this is the
// property that matters and it should not depend on a documented default.
if err := f.Chmod(0o600); err != nil {
return fmt.Errorf("chmod bootstrap token file: %w", err)
}
if _, err := f.WriteString(token + "\n"); err != nil {
return fmt.Errorf("write bootstrap token: %w", err)
}
if err := f.Sync(); err != nil {
return fmt.Errorf("sync bootstrap token: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close bootstrap token: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("install bootstrap token: %w", err)
}
return nil
}