Separate web UI into standalone metacrypt-web binary

The vault server holds in-memory unsealed state (KEK, engine keys) that
is lost on restart, requiring a full unseal ceremony. Previously the web
UI ran inside the vault process, so any UI change forced a restart and
re-unseal.

This change extracts the web UI into a separate metacrypt-web binary
that communicates with the vault over an authenticated gRPC connection.
The web server carries no sealed state and can be restarted freely.

- gen/metacrypt/v1/: generated Go bindings from proto/metacrypt/v1/
- internal/grpcserver/: full gRPC server implementation (System, Auth,
  Engine, PKI, Policy, ACME services) with seal/auth/admin interceptors
- internal/webserver/: web server with gRPC vault client; templates
  embedded via web/embed.go (no runtime web/ directory needed)
- cmd/metacrypt-web/: standalone binary entry point
- internal/config: added [web] section (listen_addr, vault_grpc, etc.)
- internal/server/routes.go: removed all web UI routes and handlers
- cmd/metacrypt/server.go: starts gRPC server alongside HTTP server
- Deploy: Dockerfile builds both binaries, docker-compose adds
  metacrypt-web service, new metacrypt-web.service systemd unit,
  Makefile gains proto/metacrypt-web targets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 09:07:12 -07:00
parent b8e348db03
commit cc1ac2e255
37 changed files with 5668 additions and 647 deletions

View File

@@ -0,0 +1,199 @@
package webserver
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
)
// VaultClient wraps the gRPC stubs for communicating with the vault.
type VaultClient struct {
conn *grpc.ClientConn
system pb.SystemServiceClient
auth pb.AuthServiceClient
engine pb.EngineServiceClient
pki pb.PKIServiceClient
}
// NewVaultClient dials the vault gRPC server and returns a client.
func NewVaultClient(addr, caCertPath string) (*VaultClient, error) {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if caCertPath != "" {
pemData, err := os.ReadFile(caCertPath)
if err != nil {
return nil, fmt.Errorf("webserver: read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("webserver: parse CA cert")
}
tlsCfg.RootCAs = pool
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return nil, fmt.Errorf("webserver: dial vault: %w", err)
}
return &VaultClient{
conn: conn,
system: pb.NewSystemServiceClient(conn),
auth: pb.NewAuthServiceClient(conn),
engine: pb.NewEngineServiceClient(conn),
pki: pb.NewPKIServiceClient(conn),
}, nil
}
// Close closes the underlying connection.
func (c *VaultClient) Close() error {
return c.conn.Close()
}
// withToken returns a context with the Bearer token in outgoing metadata.
func withToken(ctx context.Context, token string) context.Context {
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
}
// Status returns the current vault state string (e.g. "unsealed").
func (c *VaultClient) Status(ctx context.Context) (string, error) {
resp, err := c.system.Status(ctx, &pb.StatusRequest{})
if err != nil {
return "", err
}
return resp.State, nil
}
// Init initializes the vault with the given password.
func (c *VaultClient) Init(ctx context.Context, password string) error {
_, err := c.system.Init(ctx, &pb.InitRequest{Password: password})
return err
}
// Unseal unseals the vault with the given password.
func (c *VaultClient) Unseal(ctx context.Context, password string) error {
_, err := c.system.Unseal(ctx, &pb.UnsealRequest{Password: password})
return err
}
// TokenInfo holds validated token details returned by the vault.
type TokenInfo struct {
Username string
Roles []string
IsAdmin bool
}
// Login authenticates against the vault and returns the session token.
func (c *VaultClient) Login(ctx context.Context, username, password, totpCode string) (string, error) {
resp, err := c.auth.Login(ctx, &pb.LoginRequest{
Username: username,
Password: password,
TotpCode: totpCode,
})
if err != nil {
return "", err
}
return resp.Token, nil
}
// ValidateToken validates a token against the vault and returns the token info.
func (c *VaultClient) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) {
resp, err := c.auth.TokenInfo(withToken(ctx, token), &pb.TokenInfoRequest{})
if err != nil {
return nil, err
}
return &TokenInfo{
Username: resp.Username,
Roles: resp.Roles,
IsAdmin: resp.IsAdmin,
}, nil
}
// MountInfo holds metadata about an engine mount.
type MountInfo struct {
Name string
Type string
MountPath string
}
// ListMounts returns all engine mounts from the vault.
func (c *VaultClient) ListMounts(ctx context.Context, token string) ([]MountInfo, error) {
resp, err := c.engine.ListMounts(withToken(ctx, token), &pb.ListMountsRequest{})
if err != nil {
return nil, err
}
mounts := make([]MountInfo, 0, len(resp.Mounts))
for _, m := range resp.Mounts {
mounts = append(mounts, MountInfo{
Name: m.Name,
Type: m.Type,
MountPath: m.MountPath,
})
}
return mounts, nil
}
// Mount creates a new engine mount on the vault.
func (c *VaultClient) Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error {
req := &pb.MountRequest{
Name: name,
Type: engineType,
}
if len(config) > 0 {
s, err := structFromMap(config)
if err != nil {
return fmt.Errorf("webserver: encode mount config: %w", err)
}
req.Config = s
}
_, err := c.engine.Mount(withToken(ctx, token), req)
return err
}
// EngineRequest sends a generic engine operation to the vault.
func (c *VaultClient) EngineRequest(ctx context.Context, token, mount, operation string, data map[string]interface{}) (map[string]interface{}, error) {
req := &pb.EngineRequest{
Mount: mount,
Operation: operation,
}
if len(data) > 0 {
s, err := structFromMap(data)
if err != nil {
return nil, fmt.Errorf("webserver: encode engine request: %w", err)
}
req.Data = s
}
resp, err := c.engine.Request(withToken(ctx, token), req)
if err != nil {
return nil, err
}
if resp.Data == nil {
return nil, nil
}
return resp.Data.AsMap(), nil
}
// GetRootCert returns the root CA certificate PEM for the given mount.
func (c *VaultClient) GetRootCert(ctx context.Context, mount string) ([]byte, error) {
resp, err := c.pki.GetRootCert(ctx, &pb.GetRootCertRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.CertPem, nil
}
// GetIssuerCert returns a named issuer certificate PEM for the given mount.
func (c *VaultClient) GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error) {
resp, err := c.pki.GetIssuerCert(ctx, &pb.GetIssuerCertRequest{Mount: mount, Issuer: issuer})
if err != nil {
return nil, err
}
return resp.CertPem, nil
}

View File

@@ -0,0 +1,430 @@
package webserver
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) registerRoutes(r chi.Router) {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(ws.staticFS))))
r.Get("/", ws.handleRoot)
r.HandleFunc("/init", ws.handleInit)
r.HandleFunc("/unseal", ws.handleUnseal)
r.HandleFunc("/login", ws.handleLogin)
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
r.Route("/pki", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePKI))
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
})
}
// requireAuth validates the token cookie against the vault and injects TokenInfo.
func (ws *WebServer) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state, err := ws.vault.Status(r.Context())
if err != nil || state != "unsealed" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
token := extractCookie(r)
if token == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
info, err := ws.vault.ValidateToken(r.Context(), token)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
r = r.WithContext(withTokenInfo(r.Context(), info))
next(w, r)
}
}
func (ws *WebServer) handleRoot(w http.ResponseWriter, r *http.Request) {
state, err := ws.vault.Status(r.Context())
if err != nil {
ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": "Cannot reach vault"})
return
}
switch state {
case "uninitialized", "initializing":
http.Redirect(w, r, "/init", http.StatusFound)
case "sealed":
http.Redirect(w, r, "/unseal", http.StatusFound)
case "unsealed":
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Redirect(w, r, "/unseal", http.StatusFound)
}
}
func (ws *WebServer) handleInit(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state, _ := ws.vault.Status(r.Context())
if state != "uninitialized" && state != "" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
ws.renderTemplate(w, "init.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if password == "" {
ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"})
return
}
if err := ws.vault.Init(r.Context(), password); err != nil {
ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": grpcMessage(err)})
return
}
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ws *WebServer) handleUnseal(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state, _ := ws.vault.Status(r.Context())
if state == "uninitialized" {
http.Redirect(w, r, "/init", http.StatusFound)
return
}
if state == "unsealed" {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
ws.renderTemplate(w, "unseal.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if err := ws.vault.Unseal(r.Context(), password); err != nil {
msg := "Invalid password"
if st, ok := status.FromError(err); ok && st.Code() == codes.ResourceExhausted {
msg = "Too many attempts. Please wait 60 seconds."
}
ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg})
return
}
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ws *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) {
state, _ := ws.vault.Status(r.Context())
if state != "unsealed" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
switch r.Method {
case http.MethodGet:
ws.renderTemplate(w, "login.html", nil)
case http.MethodPost:
r.ParseForm()
token, err := ws.vault.Login(r.Context(),
r.FormValue("username"),
r.FormValue("password"),
r.FormValue("totp_code"),
)
if err != nil {
ws.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "metacrypt_token",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
})
}
func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
r.ParseForm()
}
mountName := r.FormValue("name")
if mountName == "" {
ws.renderDashboardWithError(w, r, info, "Mount name is required")
return
}
cfg := map[string]interface{}{}
if org := r.FormValue("organization"); org != "" {
cfg["organization"] = org
}
var certPEM, keyPEM string
if f, _, err := r.FormFile("cert_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
certPEM = string(data)
}
if f, _, err := r.FormFile("key_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
keyPEM = string(data)
}
if certPEM != "" && keyPEM != "" {
cfg["root_cert_pem"] = certPEM
cfg["root_key_pem"] = keyPEM
}
token := extractCookie(r)
if err := ws.vault.Mount(r.Context(), token, mountName, "ca", cfg); err != nil {
ws.renderDashboardWithError(w, r, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) renderDashboardWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, errMsg string) {
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
"MountError": errMsg,
})
}
func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
data["RootExpired"] = time.Now().After(cert.NotAfter)
data["HasRoot"] = true
}
}
if resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = resp["issuers"]
}
ws.renderTemplate(w, "pki.html", data)
}
func (ws *WebServer) handleImportRoot(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
r.ParseForm()
}
certPEM := r.FormValue("cert_pem")
keyPEM := r.FormValue("key_pem")
if certPEM == "" {
if f, _, err := r.FormFile("cert_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
certPEM = string(data)
}
}
if keyPEM == "" {
if f, _, err := r.FormFile("key_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
keyPEM = string(data)
}
}
if certPEM == "" || keyPEM == "" {
ws.renderPKIWithError(w, r, mountName, info, "Certificate and private key are required")
return
}
_, err = ws.vault.EngineRequest(r.Context(), token, mountName, "import-root", map[string]interface{}{
"cert_pem": certPEM,
"key_pem": keyPEM,
})
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) handleCreateIssuer(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.ParseForm()
name := r.FormValue("name")
if name == "" {
ws.renderPKIWithError(w, r, mountName, info, "Issuer name is required")
return
}
reqData := map[string]interface{}{"name": name}
if v := r.FormValue("expiry"); v != "" {
reqData["expiry"] = v
}
if v := r.FormValue("max_ttl"); v != "" {
reqData["max_ttl"] = v
}
if v := r.FormValue("key_algorithm"); v != "" {
reqData["key_algorithm"] = v
}
if v := r.FormValue("key_size"); v != "" {
var size float64
if _, err := fmt.Sscanf(v, "%f", &size); err == nil {
reqData["key_size"] = size
}
}
_, err = ws.vault.EngineRequest(r.Context(), token, mountName, "create-issuer", reqData)
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
issuerName := chi.URLParam(r, "issuer")
certPEM, err := ws.vault.GetIssuerCert(r.Context(), mountName, issuerName)
if err != nil {
http.Error(w, "issuer not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.pem", issuerName))
w.Write(certPEM)
}
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Error": errMsg,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
data["RootExpired"] = time.Now().After(cert.NotAfter)
data["HasRoot"] = true
}
}
ws.renderTemplate(w, "pki.html", data)
}
func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return "", err
}
for _, m := range mounts {
if m.Type == "ca" {
return m.Name, nil
}
}
return "", fmt.Errorf("no CA engine mounted")
}
// grpcMessage extracts a human-readable message from a gRPC error.
func grpcMessage(err error) string {
if st, ok := status.FromError(err); ok {
return st.Message()
}
return err.Error()
}

View File

@@ -0,0 +1,112 @@
// Package webserver implements the standalone web UI server for Metacrypt.
// It communicates with the vault over gRPC and renders server-side HTML.
package webserver
import (
"context"
"crypto/tls"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
webui "git.wntrmute.dev/kyle/metacrypt/web"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
)
// WebServer is the standalone web UI server.
type WebServer struct {
cfg *config.Config
vault *VaultClient
logger *slog.Logger
httpSrv *http.Server
staticFS fs.FS
}
// New creates a new WebServer. It dials the vault gRPC endpoint.
func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert)
if err != nil {
return nil, fmt.Errorf("webserver: connect to vault: %w", err)
}
staticFS, err := fs.Sub(webui.FS, "static")
if err != nil {
return nil, fmt.Errorf("webserver: static FS: %w", err)
}
return &WebServer{
cfg: cfg,
vault: vault,
logger: logger,
staticFS: staticFS,
}, nil
}
// Start starts the web server. It blocks until the server is closed.
func (ws *WebServer) Start() error {
r := chi.NewRouter()
ws.registerRoutes(r)
ws.httpSrv = &http.Server{
Addr: ws.cfg.Web.ListenAddr,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
ws.logger.Info("starting web server", "addr", ws.cfg.Web.ListenAddr)
if ws.cfg.Web.TLSCert != "" && ws.cfg.Web.TLSKey != "" {
ws.httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
err := ws.httpSrv.ListenAndServeTLS(ws.cfg.Web.TLSCert, ws.cfg.Web.TLSKey)
if err != nil && err != http.ErrServerClosed {
return fmt.Errorf("webserver: %w", err)
}
return nil
}
err := ws.httpSrv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
return fmt.Errorf("webserver: %w", err)
}
return nil
}
// Shutdown gracefully shuts down the web server.
func (ws *WebServer) Shutdown(ctx context.Context) error {
_ = ws.vault.Close()
if ws.httpSrv != nil {
return ws.httpSrv.Shutdown(ctx)
}
return nil
}
func (ws *WebServer) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
tmpl, err := template.ParseFS(webui.FS,
"templates/layout.html",
"templates/"+name,
)
if err != nil {
ws.logger.Error("parse template", "name", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
ws.logger.Error("execute template", "name", name, "error", err)
}
}
func extractCookie(r *http.Request) string {
c, err := r.Cookie("metacrypt_token")
if err != nil {
return ""
}
return c.Value
}

View File

@@ -0,0 +1,37 @@
package webserver
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"google.golang.org/protobuf/types/known/structpb"
)
type contextKey int
const tokenInfoCtxKey contextKey = iota
func withTokenInfo(ctx context.Context, info *TokenInfo) context.Context {
return context.WithValue(ctx, tokenInfoCtxKey, info)
}
func tokenInfoFromContext(ctx context.Context) *TokenInfo {
v, _ := ctx.Value(tokenInfoCtxKey).(*TokenInfo)
return v
}
// structFromMap converts a map[string]interface{} to a *structpb.Struct.
func structFromMap(m map[string]interface{}) (*structpb.Struct, error) {
return structpb.NewStruct(m)
}
// parsePEMCert decodes the first PEM block and parses it as an x509 certificate.
func parsePEMCert(pemData []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("no PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}