Skip to content

Authentication

vgi-rpc provides built-in authentication factories for common strategies. Each factory returns an AuthenticateFn that you pass to createHttpHandler. You can also combine multiple strategies with chainAuthenticate.

AuthContext

Every authenticate callback returns an AuthContext on success:

import { AuthContext } from "@query-farm/vgi-rpc";
const ctx = new AuthContext(
"mtls", // domain — identifies the auth method
true, // authenticated
"alice", // principal — the identity
{ role: "admin" }, // claims — arbitrary metadata
);

Handlers access the context via ctx.auth:

handler: (params, ctx) => {
ctx.auth.requireAuthenticated(); // throws if not authenticated
return { user: ctx.auth.principal };
},

Bearer token

Custom validation

For API keys, opaque tokens, or tokens validated against an external service:

import { bearerAuthenticate, AuthContext } from "@query-farm/vgi-rpc";
const auth = bearerAuthenticate({
validate: (token) => {
const user = db.getUserByApiKey(token);
if (!user) throw new Error("Invalid API key");
return new AuthContext("apikey", true, user.name, { role: user.role });
},
});

Static token map

For development, testing, or a small number of pre-shared keys. Uses constant-time comparison:

import { bearerAuthenticateStatic, AuthContext } from "@query-farm/vgi-rpc";
const auth = bearerAuthenticateStatic({
tokens: {
"key-abc123": new AuthContext("apikey", true, "alice"),
"key-def456": new AuthContext("apikey", true, "bob", { role: "admin" }),
},
});

Accepts Record<string, AuthContext> or ReadonlyMap<string, AuthContext>.

JWT

Validates JWT tokens using OIDC discovery. Requires the oauth4webapi peer dependency.

import { jwtAuthenticate } from "@query-farm/vgi-rpc";
const auth = jwtAuthenticate({
issuer: "https://auth.example.com",
audience: "https://api.example.com/vgi",
});
OptionTypeDefaultDescription
issuerstringrequiredExpected iss claim (used for OIDC discovery)
audiencestringrequiredExpected aud claim
jwksUristringExplicit JWKS URL (discovered from issuer if omitted)
principalClaimstring"sub"JWT claim to use as principal
domainstring"jwt"AuthContext.domain value

Mutual TLS (mTLS)

For proxy-terminated mTLS connections where the reverse proxy forwards client certificate information in HTTP headers. Two approaches are supported:

  • PEM-in-header — the proxy forwards the URL-encoded PEM certificate (e.g., nginx X-SSL-Client-Cert, AWS ALB X-Amzn-Mtls-Clientcert)
  • XFCC — the proxy forwards an Envoy x-forwarded-client-cert structured header

Subject CN (simplest)

Extracts the Subject Common Name as principal and populates claims with the DN, serial number, and expiry:

import { mtlsAuthenticateSubject } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateSubject({
allowedSubjects: new Set(["my-service", "other-service"]),
checkExpiry: true,
});
OptionTypeDefaultDescription
headerstring"X-SSL-Client-Cert"Header containing URL-encoded PEM
domainstring"mtls"AuthContext.domain value
allowedSubjectsReadonlySet<string> | nullnullRestrict accepted CNs (null = accept any)
checkExpirybooleanfalseReject expired or not-yet-valid certificates

The returned AuthContext has these claims:

ClaimDescription
subject_dnFull DN string (e.g., "CN=my-service")
serialSerial number as hex string
not_valid_afterExpiry as ISO 8601 string

Fingerprint lookup

Maps certificate fingerprints to pre-configured identities. Useful when you have a known set of client certificates:

import { mtlsAuthenticateFingerprint, AuthContext } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateFingerprint({
fingerprints: {
"a1b2c3d4...": new AuthContext("mtls", true, "service-a"),
"e5f6a7b8...": new AuthContext("mtls", true, "service-b", { env: "prod" }),
},
algorithm: "sha256", // default; also supports sha1, sha384, sha512
});

Fingerprints must be lowercase hex without colons. Throws at construction time if the algorithm is unsupported.

OptionTypeDefaultDescription
fingerprintsRecord<string, AuthContext> | ReadonlyMap<string, AuthContext>requiredFingerprint-to-context map
headerstring"X-SSL-Client-Cert"Header containing URL-encoded PEM
algorithmstring"sha256"Hash algorithm
checkExpirybooleanfalseReject expired certificates

Custom validation

For full control over certificate inspection:

import { mtlsAuthenticate, AuthContext } from "@query-farm/vgi-rpc";
import type { X509Certificate } from "node:crypto";
const auth = mtlsAuthenticate({
validate: (cert: X509Certificate) => {
// Inspect cert.subject, cert.issuer, cert.serialNumber, etc.
return new AuthContext("mtls", true, "my-principal", {});
},
header: "X-Amzn-Mtls-Clientcert", // AWS ALB header
checkExpiry: true,
});
OptionTypeDefaultDescription
validateCertValidateFnrequiredReceives X509Certificate, returns AuthContext or throws
headerstring"X-SSL-Client-Cert"Header containing URL-encoded PEM
checkExpirybooleanfalseCheck validity period before calling validate

XFCC (Envoy)

Parses the x-forwarded-client-cert structured header set by Envoy proxy. Does not require certificate parsing — works with the structured fields directly:

import { mtlsAuthenticateXfcc } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateXfcc();
// Extracts CN from Subject field as principal

With custom validation:

import { mtlsAuthenticateXfcc, AuthContext } from "@query-farm/vgi-rpc";
import type { XfccElement } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateXfcc({
validate: (element: XfccElement) => {
if (element.hash !== "expected-hash") throw new Error("Unknown client");
return new AuthContext("xfcc", true, "trusted-service", {});
},
selectElement: "first", // "first" (original client) or "last" (nearest proxy)
});
OptionTypeDefaultDescription
validateXfccValidateFnCustom validation (default: extract CN from Subject)
domainstring"mtls"AuthContext.domain value
selectElement"first" | "last""first"Which element to use when multiple are present

Chaining authenticators

Accept multiple authentication methods on the same endpoint with chainAuthenticate. Authenticators are tried in order — credential errors (plain Error) fall through to the next; other exceptions propagate immediately.

import {
chainAuthenticate,
mtlsAuthenticateSubject,
bearerAuthenticateStatic,
AuthContext,
} from "@query-farm/vgi-rpc";
const auth = chainAuthenticate(
mtlsAuthenticateSubject({ allowedSubjects: new Set(["my-service"]) }),
bearerAuthenticateStatic({
tokens: { "sk-ci-bot": new AuthContext("apikey", true, "ci-bot") },
}),
);
const handler = createHttpHandler(protocol, {
signingKey: myKey,
authenticate: auth,
});

When a request arrives with a client certificate header, mTLS is tried first. If the header is missing, the chain falls through to bearer token authentication.

ScenarioException typeResult
Credentials accepted(none)Returns AuthContext, stops chain
Bad / missing credentialsPlain ErrorTries next authenticator
Authenticated but forbiddenError with name === "PermissionError"Propagates immediately
Bug in authenticatorTypeError, RangeError, etc.Propagates immediately

Wiring it up

Pass any authenticate callback to createHttpHandler:

import { Protocol, createHttpHandler, float } from "@query-farm/vgi-rpc";
const protocol = new Protocol("MyService");
protocol.unary("add", {
params: { a: float, b: float },
result: { result: float },
handler: ({ a, b }) => ({ result: a + b }),
});
const handler = createHttpHandler(protocol, {
signingKey: myKey,
authenticate: auth, // any AuthenticateFn from above
});
Bun.serve({ port: 8080, fetch: handler });

Unauthenticated requests receive a 401 Unauthorized response.