package store import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "sync" "time" ) // ErrTokenNotFound is returned for an unknown, revoked, or not-owned token. var ErrTokenNotFound = errors.New("token not found") // APIToken is a personal access token, scoped to the account that created it. // The plaintext is only ever returned once, by Create. type APIToken struct { ID string `json:"id"` UserID string `json:"userId"` Name string `json:"name"` Hint string `json:"hint"` // last 4 characters, for telling tokens apart in a list Hash string `json:"hash"` CreatedAt time.Time `json:"createdAt"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` } // Tokens is a JSON-backed collection of personal API tokens. type Tokens struct { path string mu sync.RWMutex // items holds every token by id. items map[string]APIToken // byHash maps a token's sha256 hex digest to its id, for authentication // lookups without ever storing the plaintext. byHash map[string]string } // NewTokens loads (or creates) the tokens file at path. func NewTokens(path string) (*Tokens, error) { t := &Tokens{path: path, items: map[string]APIToken{}, byHash: map[string]string{}} if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("create data directory: %w", err) } b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return t, nil } if err != nil { return nil, fmt.Errorf("read tokens: %w", err) } var list []APIToken if err := json.Unmarshal(b, &list); err != nil { return nil, fmt.Errorf("parse tokens file %s: %w", path, err) } for _, tok := range list { t.items[tok.ID] = tok t.byHash[tok.Hash] = tok.ID } return t, nil } // List returns every token owned by userID, hash stripped, newest first. func (t *Tokens) List(userID string) []APIToken { t.mu.RLock() defer t.mu.RUnlock() out := make([]APIToken, 0, len(t.items)) for _, tok := range t.items { if tok.UserID == userID { out = append(out, redactToken(tok)) } } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) return out } // Create mints a new token for userID and returns it (hash stripped) plus the // plaintext secret, which is never stored and never retrievable again. func (t *Tokens) Create(userID, name string) (APIToken, string, error) { plain, err := newTokenSecret() if err != nil { return APIToken{}, "", fmt.Errorf("generate token: %w", err) } hash := hashToken(plain) t.mu.Lock() defer t.mu.Unlock() tok := APIToken{ ID: newID(), UserID: userID, Name: name, Hint: plain[len(plain)-4:], Hash: hash, CreatedAt: time.Now(), } t.items[tok.ID] = tok t.byHash[hash] = tok.ID if err := t.flush(); err != nil { return APIToken{}, "", err } return redactToken(tok), plain, nil } // Authenticate looks up the token behind a plaintext secret and records it as // used. It does not check ownership: any valid token authenticates as its // owner, which the caller then treats as the request's identity. func (t *Tokens) Authenticate(plain string) (APIToken, error) { hash := hashToken(plain) t.mu.Lock() defer t.mu.Unlock() id, ok := t.byHash[hash] if !ok { return APIToken{}, ErrTokenNotFound } tok := t.items[id] now := time.Now() tok.LastUsedAt = &now t.items[id] = tok if err := t.flush(); err != nil { return APIToken{}, err } return redactToken(tok), nil } // Revoke deletes a token, refusing if it is not owned by userID. func (t *Tokens) Revoke(userID, id string) error { t.mu.Lock() defer t.mu.Unlock() tok, ok := t.items[id] if !ok || tok.UserID != userID { return ErrTokenNotFound } delete(t.items, id) delete(t.byHash, tok.Hash) return t.flush() } // RevokeAllForUser deletes every token owned by userID, e.g. when the account // itself is deleted. func (t *Tokens) RevokeAllForUser(userID string) error { t.mu.Lock() defer t.mu.Unlock() for id, tok := range t.items { if tok.UserID == userID { delete(t.items, id) delete(t.byHash, tok.Hash) } } return t.flush() } // flush writes the file. The caller must hold the write lock. func (t *Tokens) flush() error { list := make([]APIToken, 0, len(t.items)) for _, tok := range t.items { list = append(list, tok) } sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) b, err := json.MarshalIndent(list, "", " ") if err != nil { return err } tmp := t.path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return fmt.Errorf("write tokens: %w", err) } if err := os.Rename(tmp, t.path); err != nil { return fmt.Errorf("replace tokens file: %w", err) } return nil } func redactToken(tok APIToken) APIToken { tok.Hash = "" return tok } // newTokenSecret generates a token in the form dmv_<48 hex characters>. func newTokenSecret() (string, error) { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return "", err } return "dmv_" + hex.EncodeToString(b), nil } func hashToken(plain string) string { sum := sha256.Sum256([]byte(plain)) return hex.EncodeToString(sum[:]) }