- 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>
123 lines
4.0 KiB
Go
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
|
|
}
|