- 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 ./...)
124 lines
4.0 KiB
Go
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
|
|
}
|