Files
mcias/internal/grpcserver/tokenservice.go
Kyle Isom f34e9a69a0 Fix all golangci-lint warnings
- errorlint: use errors.Is for db.ErrNotFound comparisons
  in accountservice.go, credentialservice.go, tokenservice.go
- gofmt/goimports: move mciasv1 alias into internal import group
  in auth.go, credentialservice.go, grpcserver.go, grpcserver_test.go
- gosec G115: add nolint annotation on int32 port conversions
  in mciasgrpcctl/main.go and credentialservice.go (port validated
  as [1,65535] on input; overflow not reachable)
- govet fieldalignment: reorder Server, grpcRateLimiter,
  grpcRateLimitEntry, testEnv structs to reduce GC bitmap size
  (96 -> 80 pointer bytes each)
- ineffassign: remove intermediate grpcSrv = GRPCServer() call
  in cmd/mciassrv/main.go (immediately overwritten by TLS build)
- staticcheck SA9003: replace empty if-body with _ = Serve(lis)
  in grpcserver_test.go
0 golangci-lint issues; 137 tests pass (go test -race ./...)
2026-03-11 15:24:07 -07:00

124 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")
}
// Revoke existing system token if any.
existing, err := ts.s.db.GetSystemToken(acct.ID)
if err == nil && existing != nil {
_ = ts.s.db.RevokeToken(existing.JTI, "rotated")
}
if err := ts.s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
if err := ts.s.db.SetSystemToken(acct.ID, claims.JTI, 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
}