// 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 }