- proto/mcias/v1/: AdminService, AuthService, TokenService, AccountService, CredentialService; generated Go stubs in gen/ - internal/grpcserver: full handler implementations sharing all business logic (auth, token, db, crypto) with REST server; interceptor chain: logging -> auth (JWT alg-first + revocation) -> rate-limit (token bucket, 10 req/s, burst 10, per-IP) - internal/config: optional grpc_addr field in [server] section - cmd/mciassrv: dual-stack startup; gRPC/TLS listener on grpc_addr when configured; graceful shutdown of both servers in 15s window - cmd/mciasgrpcctl: companion gRPC CLI mirroring mciasctl commands (health, pubkey, account, role, token, pgcreds) using TLS with optional custom CA cert - internal/grpcserver/grpcserver_test.go: 20 tests via bufconn covering public RPCs, auth interceptor (no token, invalid, revoked -> 401), non-admin -> 403, Login/Logout/RenewToken/ValidateToken flows, AccountService CRUD, SetPGCreds/GetPGCreds AES-GCM round-trip, credential fields absent from all responses Security: JWT validation path identical to REST: alg header checked before signature, alg:none rejected, revocation table checked after sig. Authorization metadata value never logged by any interceptor. Credential fields (PasswordHash, TOTPSecret*, PGPassword) absent from all proto response messages — enforced by proto design and confirmed by test TestCredentialFieldsAbsentFromAccountResponse. Login dummy-Argon2 timing guard preserves timing uniformity for unknown users (same as REST handleLogin). TLS required at listener level; cmd/mciassrv uses credentials.NewServerTLSFromFile; no h2c offered. 137 tests pass, zero race conditions (go test -race ./...)
123 lines
4.0 KiB
Go
123 lines
4.0 KiB
Go
// tokenServiceServer implements mciasv1.TokenServiceServer.
|
|
package grpcserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
|
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
|
)
|
|
|
|
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 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
|
|
}
|