Files
mcias/internal/grpcserver/tokenservice.go
Kyle Isom 51a5277062 Fix SEC-08: make system token issuance atomic
- Add IssueSystemToken() method in internal/db/accounts.go that wraps
  revoke-old, track-new, and upsert-system_tokens in a single SQLite
  transaction
- Update handleTokenIssue in internal/server/server.go to use the new
  atomic method instead of three separate DB calls
- Update IssueServiceToken in internal/grpcserver/tokenservice.go with
  the same fix
- Add TestIssueSystemTokenAtomic test covering first issue and rotation

Security: token issuance now uses a single transaction to prevent
inconsistent state (e.g., old token revoked but new token not tracked)
if a crash occurs between operations. Follows the same pattern as
RenewToken which was already correctly transactional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:13 -07:00

123 lines
4.0 KiB
Go

// tokenServiceServer implements mciasv1.TokenServiceServer.
package grpcserver
import (
"context"
"errors"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
)
type tokenServiceServer struct {
mciasv1.UnimplementedTokenServiceServer
s *Server
}
// ValidateToken validates a JWT and returns its claims.
// Public RPC — no auth required.
//
// Security: Always returns a valid=false response on any error; never
// exposes which specific validation step failed.
func (t *tokenServiceServer) ValidateToken(_ context.Context, req *mciasv1.ValidateTokenRequest) (*mciasv1.ValidateTokenResponse, error) {
tokenStr := req.Token
if tokenStr == "" {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
claims, err := token.ValidateToken(t.s.pubKey, tokenStr, t.s.cfg.Tokens.Issuer)
if err != nil {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
rec, err := t.s.db.GetTokenRecord(claims.JTI)
if err != nil || rec.IsRevoked() {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
return &mciasv1.ValidateTokenResponse{
Valid: true,
Subject: claims.Subject,
Roles: claims.Roles,
ExpiresAt: timestamppb.New(claims.ExpiresAt),
}, nil
}
// IssueServiceToken issues a token for a system account. Admin only.
func (ts *tokenServiceServer) IssueServiceToken(ctx context.Context, req *mciasv1.IssueServiceTokenRequest) (*mciasv1.IssueServiceTokenResponse, error) {
if err := ts.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.AccountId == "" {
return nil, status.Error(codes.InvalidArgument, "account_id is required")
}
acct, err := ts.s.db.GetAccountByUUID(req.AccountId)
if err != nil {
return nil, status.Error(codes.NotFound, "account not found")
}
if acct.AccountType != model.AccountTypeSystem {
return nil, status.Error(codes.InvalidArgument, "token issue is only for system accounts")
}
tokenStr, claims, err := token.IssueToken(ts.s.privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry())
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
// Atomically revoke existing system token (if any), track the new token,
// and update system_tokens — all in a single transaction.
// Security: prevents inconsistent state if a crash occurs mid-operation.
var oldJTI string
existing, err := ts.s.db.GetSystemToken(acct.ID)
if err == nil && existing != nil {
oldJTI = existing.JTI
}
if err := ts.s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
actorClaims := claimsFromContext(ctx)
var actorID *int64
if actorClaims != nil {
if a, err := ts.s.db.GetAccountByUUID(actorClaims.Subject); err == nil {
actorID = &a.ID
}
}
ts.s.db.WriteAuditEvent(model.EventTokenIssued, actorID, &acct.ID, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"jti":%q}`, claims.JTI))
return &mciasv1.IssueServiceTokenResponse{
Token: tokenStr,
ExpiresAt: timestamppb.New(claims.ExpiresAt),
}, nil
}
// RevokeToken revokes a token by JTI. Admin only.
func (ts *tokenServiceServer) RevokeToken(ctx context.Context, req *mciasv1.RevokeTokenRequest) (*mciasv1.RevokeTokenResponse, error) {
if err := ts.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.Jti == "" {
return nil, status.Error(codes.InvalidArgument, "jti is required")
}
if err := ts.s.db.RevokeToken(req.Jti, "admin revocation"); err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "token not found or already revoked")
}
return nil, status.Error(codes.Internal, "internal error")
}
ts.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"jti":%q}`, req.Jti))
return &mciasv1.RevokeTokenResponse{}, nil
}