Our OpenID server got a second transport: gRPC beside HTTP, on the same routes

redb.Identity

We have said more than once that redb.Identity is transport-agnostic: all the logic lives in the core behind direct-vm://identity-* addresses, and HTTP is merely a facade on top of them. It sounded convincing, but there was nothing to check the claim against. There was exactly one facade, and "agnostic" stayed an architectural promise rather than an observable fact.

Now there are two. gRPC stands beside HTTP: the same core routes, the same issuer, the same client registry, the same token store. One and the same token is accepted by both transports and gets the same verdict from each. That is what this article is about: what appeared, how to switch it on, and why it fits particularly well where gRPC has already become the internal language.

Who this is for

Picture a network where services have spoken gRPC for a long time. There is a service mesh, there are deadlines on calls, there is mTLS between pods, there are clients generated from .proto files, and tracing that understands gRPC statuses. And in the middle of all that, one service you reach differently: the authorization server over HTTP, with a form-encoded body, its own client, its own error handling and its own way of saying "denied".

This is not a catastrophe, it is a seam. Seams cost money: a separate HTTP client in every service, separate retry policies, errors that do not fit the common shape, and telemetry where fetching a token looks foreign next to everything else.

The gRPC facade removes the seam. A token is fetched over the same channel, the same way as any other internal call:

var channel = GrpcChannel.ForAddress("https://identity.internal:5001");
var identity = new Identity.IdentityClient(channel);

var token = await identity.TokenAsync(new TokenRequest
{
    GrantType    = "client_credentials",
    ClientId     = "reporting-service",
    ClientSecret = secret,
    Scope        = "identity:users:read",
});

A deadline is set the way it is set everywhere, mTLS is configured the way it is configured everywhere, and a failure arrives as a status your interceptor already knows how to read.

What exactly appeared

The protocol surface, which is what an authorization server exists for as far as machines are concerned:

Operation Address Specification
Token /identity.v1.Identity/Token RFC 6749 §3.2
Introspect /identity.v1.Identity/Introspect RFC 7662
Revoke /identity.v1.Identity/Revoke RFC 7009
UserInfo /identity.v1.Identity/UserInfo OIDC Core §5.3
Discovery /identity.v1.Identity/Discovery OIDC Discovery 1.0
Jwks /identity.v1.Identity/Jwks RFC 7517
health probe /grpc.health.v1.Health/Check gRPC Health Checking

Plus a management surface: forty operations across five services (Users, Applications, Groups, Scopes, Tokens), on a port of its own.

One method address is one route. Every operation carries its own RouteId, its own metrics, its own lifecycle, and can be suspended individually through the control bus. Not one dispatcher with a switch inside, but seven and forty ordinary routes, visible one by one in the dashboard.

The contract lives in the repository: redb.Identity.Contracts/Protos/identity.v1.proto. It is published precisely as a file consumers generate their own stubs from, and deliberately not compiled into the Contracts assembly, so that one stays a dependency-free package.

Message shape

Requests name what the RFCs name, plus a map<string, string> for everything else. That is not a shortcut: the wire form of these endpoints has always been a set of key-value pairs, so extension grants and vendor parameters belong exactly there.

Responses type what the RFCs fix and put the rest into a google.protobuf.Struct. Userinfo claims and additional introspection members are open sets by the spec's design, and typing them would be a lie. Nothing the server returns is dropped.

Failures arrive as a status, not as a body

Here is a detail that HTTP settles by itself and gRPC makes you decide. An OAuth error is a document in the response body. Hand it back on a successful call and no generated client will look inside: it sees OK and moves on with an empty structure.

So a refusal arrives as a status:

Cause Status
invalid_client, invalid_token UNAUTHENTICATED
access_denied, unauthorized_client PERMISSION_DENIED
server_error INTERNAL
rate limited (429) RESOURCE_EXHAUSTED
scope guard (403) PERMISSION_DENIED
any other OAuth error INVALID_ARGUMENT

And the machine-readable code travels in a trailer, because the protocol discards the payload of a non-OK reply:

try
{
    await identity.TokenAsync(request);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.ResourceExhausted)
{
    var wait = ex.Trailers.GetValue("retry-after");   // seconds
    var code = ex.Trailers.GetValue("error");         // rate_limited, for instance
}

The same place returns x-correlation-id, yours or one derived from the ambient trace, so the caller's logs stitch to ours with no extra work.

The check that matters: one token, two transports, one verdict

A facade is worth exactly as much as it is not a second authorization server. That is proven by one scenario, and it lives in a demo rather than in promises.

Take a client holding identity:users:write. It is admitted to a user operation over HTTP and over gRPC alike. Take a client holding only identity:users:read. It is refused the write on both transports: PERMISSION_DENIED over gRPC and 403 over HTTP.

This is not two configurations that happen to agree. The scope table moved into the core, behind direct-vm://identity-authz-check, and both facades ask it. Two copies of such a table drift in exactly one direction: one starts granting more than the other, on the surface where that is most expensive.

A client registered over HTTP through dynamic registration gets its token over gRPC immediately. Same issuer, same registry, same token store.

Boundaries, named up front

Browser flows stay on HTTP: authorize, login, the consent screen, MFA pages, device verification. They need a browser, redirects and a cookie session, and a gRPC channel has none of the three.

DPoP stays on HTTP too: RFC 9449 binds its proof to an HTTP method and URL, so a DPoP proof presented over gRPC would be unverifiable by construction.

Self-service (/me, registration, password recovery, MFA enrolment) is deliberately not on gRPC. Those are end-user flows from a browser or a mobile app, not admin tooling, and on an admin port they would only widen the blast radius.

Ports are not shared with the HTTP facade, and that is physics rather than preference: gRPC requires HTTP/2, the HTTP facade serves HTTP/1.1 and HTTP/2, and one listener has a single protocol set. The shared host fails on that conflict immediately, instead of later with a framing error.

Ten seconds to see it

The easiest way is grpcurl, one binary, nothing to build. We do not serve reflection, so point it at the published contract from the repository, exactly as a consumer would:

grpcurl -plaintext \
  -import-path src/redb.Identity.Contracts/Protos -proto identity.v1.proto \
  -d '{}' localhost:5011 identity.v1.Identity/Discovery

A demo ships with it, ten steps, printing everything that goes out and comes back:

pwsh -File demos/demo_grpc_facade.ps1

Health, discovery, registering a client over HTTP, a token over gRPC for that same client, introspection, a refusal on a wrong secret with its status and trailer, the management gate without a token and with one, and finally the verdicts compared across both transports. The whole run takes about ten seconds.

There is also a C# viewer if you would rather see this from code: dotnet run in demos/grpc-viewer. It compiles the same two .proto files the way any consumer would.

What it costs in time

Figures from a live worker, measured inside one process, median over a series of calls:

Operation Median
Health/Check 4.2 ms
Discovery (full path through the core) 7.5 ms

The second column is not "network time" but the full round trip: client, facade, direct-vm, core, back.

What covers it

Sixty-four tests on the facade itself: protocol operations, the negative matrix (wrong secret, unknown client, a scope the client may not have, a garbage token), split ports, the management gate.

Interop stands apart: an @grpc/grpc-js client in a container, generated from the published .proto the way any consumer would generate it. It calls the facade from another process and shares not one line of code with us. That matters more than it looks: our own tests run our own marshallers against our own listener, and a contract that disagreed with itself consistently would pass them unnoticed.

The gate is checked just as distrustfully. Green tests on authorization are not enough; you want to know the green is held up by something. We removed both checks in a scratch working copy and looked at what would turn red: precisely the security tests did, while the ones that do not depend on the gate stayed green. So the gate is load-bearing rather than decorative.

Deployment

The facade ships as its own Tsak module with its own identity.grpc context and not a single compile-time reference to redb.Identity.Core: it talks to the core over direct-vm:// only. Deploy it beside the core module, and beside the HTTP facade if you serve browsers.

Configuration lives in the shared context.json, in a section shaped the same way as the HTTP facade's:

"identity.grpc": {
  "IdentityTransport": {
    "Grpc": {
      "Host": "0.0.0.0",
      "PublicPort": 5011,
      "ManagementPort": null,
      "Ssl": false,
      "ClientCertificateMode": "NoCertificate",
      "Compression": "None",
      "Health": true,
      "EmitHttpCompatHeaders": true
    }
  }
}

Two settings deserve a word.

ManagementPort defaults to sharing the public one, which is sensible for a single node. In production it is worth splitting: the protocol surface is called by every relying party, the management surface only by admin tooling. Different consumers, different blast radius, different firewall rule.

EmitHttpCompatHeaders is on by default, and should stay on. The core keys its per-IP rate limit, its lockout after failed sign-ins and its device metadata on the client address. Those checks do not fail when the address is missing, they quietly do nothing. Turn the flag off and you lose no visible feature, you lose protection while tests and dashboards stay green. Callers cannot forge the address: the transport drops inbound metadata carrying a reserved prefix.

What comes next

Today gRPC carries the protocol surface and five management groups. The rest of the management surface, 142 operations in total, opens as needed: the mechanics of the phase are built and proven, and adding a group is rows in a table plus tests for them.

If gRPC is already your internal language, try the cheapest thing: start the worker and run that grpcurl against Discovery. It answers with a real document from the core, including the HTTP addresses of its endpoints. We deliberately do not rewrite those: they are the real ones, and swapping in gRPC addresses would hand the consumer something that does not exist.

There is a separate write-up on putting this same server through the official OpenID Foundation conformance suite: running our OpenID server through the official suite.

If this was useful — a ⭐ on GitHub helps others find it.

More of my writing: redbase.app/articles, and on dev.to.