Files
platform/auth/apikey.go
T
mats 0f8ed97878 fix(auth): write the shared API-key store world-readable so other services see key changes
The key store holds only SHA-256 hashes + metadata (no plaintext keys) and is
read by other services in their own containers running as different non-root
uids. saveLocked wrote it 0600, so when the backoffice pod (running as root)
minted or revoked a key the file became root-owned 0600 — unreadable to the
ledger/cart services, freezing their mtime-reload snapshots: new keys 401'd
and revocations never took effect. Write 0644 so every reader reloads.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-17 21:53:37 +02:00

205 lines
5.8 KiB
Go

package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
// APIKey is the stored record for a minted key. Only the SHA-256 of the
// full key string is kept; the key itself is shown exactly once at mint
// time. SHA-256 (not argon2/bcrypt) is sufficient because keys are 192-bit
// random secrets, not low-entropy passwords.
type APIKey struct {
ID string `json:"id"`
Name string `json:"name"`
Hash string `json:"hash"` // hex sha256 of the full key string
Scopes []string `json:"scopes,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// ErrKeyNotFound is returned by Revoke for an unknown key id.
var ErrKeyNotFound = errors.New("auth: api key not found")
// KeyStore is a file-backed set of API keys (<file> is JSON, written
// atomically). Volume is tiny — a handful of integration keys — so a flat
// file beats dragging key records into the event-sourced graph store.
//
// One store can be shared across services (e.g. /commerce/apikeys.json on a
// common volume): the writer mints/revokes in-process, readers poll with
// ReloadIfChanged so a newly minted key works without a restart.
type KeyStore struct {
path string
mu sync.Mutex
keys []APIKey
modTime time.Time // file mtime seen at the last load
}
// OpenKeyStore loads (or initializes) the key store at path.
func OpenKeyStore(path string) (*KeyStore, error) {
s := &KeyStore{path: path}
if err := s.ReloadIfChanged(); err != nil {
return nil, err
}
return s, nil
}
// Mint creates a key with the given display name and scopes, persists its
// hash, and returns the full key string — the only time it is available.
func (s *KeyStore) Mint(name string, scopes []string) (key string, rec APIKey, err error) {
idBytes := make([]byte, 4)
secret := make([]byte, 24)
if _, err = rand.Read(idBytes); err != nil {
return "", APIKey{}, err
}
if _, err = rand.Read(secret); err != nil {
return "", APIKey{}, err
}
id := hex.EncodeToString(idBytes)
key = "gck_" + id + "_" + base64.RawURLEncoding.EncodeToString(secret)
rec = APIKey{
ID: id,
Name: name,
Hash: hashKey(key),
Scopes: scopes,
CreatedAt: time.Now().UTC(),
}
s.mu.Lock()
defer s.mu.Unlock()
s.keys = append(s.keys, rec)
if err := s.saveLocked(); err != nil {
s.keys = s.keys[:len(s.keys)-1]
return "", APIKey{}, err
}
s.modTime = time.Now()
return key, rec, nil
}
// Revoke deletes the key with the given id.
func (s *KeyStore) Revoke(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, k := range s.keys {
if k.ID == id {
s.keys = append(s.keys[:i], s.keys[i+1:]...)
err := s.saveLocked()
s.modTime = time.Now()
return err
}
}
return ErrKeyNotFound
}
// List returns the stored key records. Callers serving them over HTTP
// should strip Hash themselves — it isn't secret-bearing, but is also not
// a client concern.
func (s *KeyStore) List() []APIKey {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]APIKey, len(s.keys))
copy(out, s.keys)
return out
}
// lookup finds the record whose hash matches the presented key.
func (s *KeyStore) lookup(presented string) (APIKey, bool) {
h := []byte(hashKey(presented))
s.mu.Lock()
defer s.mu.Unlock()
for _, k := range s.keys {
if subtle.ConstantTimeCompare(h, []byte(k.Hash)) == 1 {
return k, true
}
}
return APIKey{}, false
}
// ReloadIfChanged re-reads the backing file when its mtime changed since the
// last load (the store may have been updated by another process — the CMS
// mints keys, services in other containers validate them). Cheap: one stat
// when nothing changed. Safe to call on every request.
func (s *KeyStore) ReloadIfChanged() error {
info, err := os.Stat(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil // no store yet — no keys, and nothing to reload
}
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if !info.ModTime().After(s.modTime) && s.keys != nil {
return nil // unchanged
}
raw, err := os.ReadFile(s.path)
if err != nil {
return err
}
var keys []APIKey
if err := json.Unmarshal(raw, &keys); err != nil {
return fmt.Errorf("auth: parse %s: %w", s.path, err)
}
s.keys = keys
s.modTime = info.ModTime()
return nil
}
// saveLocked writes the store atomically (tmp + rename). Callers hold s.mu.
// The written file's mtime is intentionally left to the filesystem so other
// processes can detect changes via ReloadIfChanged.
func (s *KeyStore) saveLocked() error {
raw, err := json.MarshalIndent(s.keys, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
// 0644, not 0600: the store holds only SHA-256 hashes + metadata (no
// plaintext keys) and is read by OTHER services in their own containers
// (they run as different, non-root uids), so the written file must be
// world-readable. A 0600 file owned by the writing pod's uid would go
// unreadable to them and freeze their ReloadIfChanged snapshots.
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func hashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// APIKeyAuth authenticates requests carrying `X-API-Key: <key>` against a
// KeyStore. The Principal's Subject is the key id and its Scopes are the
// scopes the key was minted with.
type APIKeyAuth struct {
Keys *KeyStore
}
var _ Authenticator = APIKeyAuth{}
func (a APIKeyAuth) Authenticate(r *http.Request) (*Principal, error) {
presented := r.Header.Get("X-API-Key")
if presented == "" {
return nil, ErrNoCredentials
}
rec, ok := a.Keys.lookup(presented)
if !ok {
return nil, errors.New("auth: invalid api key")
}
return &Principal{Subject: rec.ID, Scopes: rec.Scopes, Kind: KindAPIKey}, nil
}