Table of Contents

Authentication

A gateway authenticates callers with a bearer token (usually an OIDC access token), a client certificate (mTLS), Cloudflare Access in front of it, or nothing at all for local development. OpenShellClientOptions carries the first through Auth (an IAuthProvider) and the second through Tls (TlsOptions); they combine freely.

If the OpenShell CLI has registered the gateway, GatewayClient.Connect derives all of this from the registration, and you can skip most of this page. See Gateway discovery.

Providers at a glance

Provider Use
Auth.None No credentials, for example a local plaintext gateway. The default.
Auth.StaticToken A fixed bearer token.
Auth.Bearer A token fetched per request from a cache you manage.
RefreshableToken.AsAuthProvider Single-flight renewal before expiry and after an Unauthenticated response.
ClientCredentialsAuth OAuth2 client credentials for services, renewed automatically.
Auth.CloudflareAccess Cloudflare Access header and cookie, optionally on top of another provider.
Auth.WithHeaders Extra fixed headers on top of another provider.

Static and refreshable tokens

A token you already have:

var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Auth = Auth.StaticToken(Environment.GetEnvironmentVariable("OPENSHELL_TOKEN")!),
});

A token your application caches and renews elsewhere: Bearer calls your function before each request.

// Called before every request: return a cached token, not a new one each time.
var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Auth = Auth.Bearer(getTokenFromCacheAsync),
});

RefreshableToken does the caching for you. It calls your TokenRefresher shortly before the token expires (the leeway, one minute here), lets only one renewal run at a time, and renews once more when the gateway rejects a call with Unauthenticated; the client then repeats that call once. A failed renewal surfaces its error instead of serving an expired token.

var oidc = new OidcOptions
{
    Issuer = "https://idp.example.com/realms/openshell",
    ClientId = "openshell-cli",
    InMemory = true,
};

var tokens = await OidcLogin.LoginAsync(oidc);
var refreshToken = tokens.RefreshToken
    ?? throw new InvalidOperationException("the identity provider issued no refresh token");

// Renews single-flight shortly before expiry, and once after the gateway answers Unauthenticated.
var token = new RefreshableToken(
    tokens.ToAccessToken(),
    async cancellationToken =>
    {
        var renewed = await OidcLogin.RefreshAsync(oidc, refreshToken, cancellationToken);
        refreshToken = renewed.RefreshToken ?? refreshToken;   // keep the old one when it is not rotated
        return renewed.ToAccessToken();
    },
    leeway: TimeSpan.FromMinutes(1));

var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Auth = token.AsAuthProvider(),
});

OIDC

The OpenShell.Oidc namespace implements the flows the OpenShell CLI uses. Every flow discovers the issuer (/.well-known/openid-configuration) and validates it, keeps secrets and tokens in memory, and never logs them: OidcOptions and OidcTokens print no secrets from ToString.

Services: client credentials

For long-running automation, ClientCredentialsAuth exchanges a client id and secret for a token and renews it before expiry. ClientSecretProvider reads the secret on every renewal, so it can come from a vault and rotate; ClientSecret takes a fixed value instead.

// The provider owns the cached token: dispose it together with the client.
var auth = ClientCredentialsAuth.Create(new OidcOptions
{
    Issuer = "https://idp.example.com/realms/openshell",
    ClientId = "openshell-service",
    ClientSecretProvider = cancellationToken => ValueTask.FromResult(
        Environment.GetEnvironmentVariable("OPENSHELL_OIDC_CLIENT_SECRET")!),
    Audience = "openshell-gateway",
    Scopes = ["sandbox:read", "sandbox:write"],
});

var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Auth = auth,
});

OidcLogin.ClientCredentialsAsync performs a single exchange when you manage renewal yourself.

Users: browser login

OidcLogin.LoginAsync runs the authorization-code flow, with PKCE when the issuer advertises it. It opens the system browser and receives the code on a loopback port (CallbackPort, or 8000 then 18000 when it is 0), and falls back to a paste-the-code flow when no port is free; set UseKeyboardFlow to use the paste flow directly. Interactive flows request openid profile email unless you set Scopes.

// Opens the browser and receives the code on a loopback port (8000, then 18000); falls back to
// pasting the code when neither port is free. A valid cached token is returned without a prompt.
var tokens = await OidcLogin.LoginAsync(new OidcOptions
{
    Issuer = "https://idp.example.com/realms/openshell",
    ClientId = "openshell-cli",
    TokenCacheDirectory = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "my-app"),
});

using var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Auth = Auth.StaticToken(tokens.AccessToken),
});

With a token cache directory (TokenCacheDirectory, or the registered gateway's directory when Gateway is set), a still-valid cached token issued for the same issuer and client id is returned without a prompt, and new tokens are written to oidc_token.json there. Set InMemory to skip the cache.

Users without a browser: device code

OidcLogin.DeviceLoginAsync runs the RFC 8628 device flow: the user opens a URL on another device and enters a code. DisplayDeviceCode receives both; the default prints them to the console.

// For machines without a browser: the user opens the URL on another device and enters the code.
var tokens = await OidcLogin.DeviceLoginAsync(new OidcOptions
{
    Issuer = "https://idp.example.com/realms/openshell",
    ClientId = "openshell-cli",
    Timeout = TimeSpan.FromMinutes(5),
    DisplayDeviceCode = (url, code) => Console.WriteLine($"Open {url} and enter {code}"),
});

Signing in to a registered gateway

Set OidcOptions.Gateway to a registration loaded with GatewayLocator. The issuer, client id, audience, and scopes come from its metadata.json, and the tokens are cached in its oidc_token.json in the CLI's format, so the CLI and the SDK share one sign-in.

// Signs in to a registered OIDC gateway as `openshell gateway login` does: the issuer, client id,
// audience and scopes come from its metadata.json, and the tokens are cached in its oidc_token.json.
var gateway = new GatewayLocator().Load("prod");
await OidcLogin.LoginAsync(new OidcOptions { Gateway = gateway });

// GatewayClient finds the cached token and renews it with the refresh token when it expires.
using var client = GatewayClient.Connect("prod");

oidc_token.json holds access_token, refresh_token, expires_at (Unix seconds), issuer, and client_id. A token issued for another issuer or client id is never sent.

Errors

OIDC failures throw OidcException, an OpenShellException whose Failure names the step that failed (Discovery, AuthCode, DeviceCode, ClientCredentials, Refresh, Timeout, ...). Its Kind is InvalidConfig for missing or malformed settings, DeadlineExceeded for a flow that did not finish within OidcOptions.Timeout, and Auth otherwise.

Mutual TLS

The default local gateway requires a client certificate. Pass it, and a private CA when the gateway certificate is not signed by a public one, through TlsOptions:

var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://localhost:8080"),
    Tls = new TlsOptions
    {
        // A private CA for the gateway certificate; leave it null to use the system roots.
        CaCertificates = [X509Certificate2.CreateFromPemFile("ca.crt")],

        // The client certificate must include its private key.
        ClientCertificate = X509Certificate2.CreateFromPemFile("client.crt", "client.key"),
    },
});

A registered mtls gateway loads mtls/ca.crt, mtls/tls.crt, and mtls/tls.key from its credentials directory automatically.

Cloudflare Access and the edge tunnel

A gateway behind Cloudflare Access needs the Access token on every request, but the edge authenticates only GET requests, so gRPC calls go through a WebSocket tunnel. EdgeTunnelProxy starts a loopback proxy that carries the gRPC traffic over that tunnel:

// Cloudflare Access authenticates only GET requests, so gRPC goes through a local WebSocket tunnel.
await using var proxy = EdgeTunnelProxy.Start("https://gateway.example.com", edgeToken);
using var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = proxy.LocalUri,
    Auth = Auth.CloudflareAccess(edgeToken),
});

var health = await client.Health.CheckAsync();

For a registered cloudflare_jwt gateway, GatewayClient starts the proxy, points the client at it, and disposes it with the client. EdgeTunnelMode.Never sends the Access header and cookie on direct gRPC calls instead, and supplying your own Auth in GatewayConnectOptions opts out as well.

// A registered cloudflare_jwt gateway: GatewayClient starts the tunnel and disposes it with the client.
using var tunneled = GatewayClient.Connect("edge-gateway");

// Send the Access header and cookie on direct gRPC calls instead.
using var direct = GatewayClient.Connect(
    "edge-gateway",
    new GatewayConnectOptions { EdgeTunnel = EdgeTunnelMode.Never });

Edge tokens must use cookie-safe JWT characters.

Extra headers

var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Auth = Auth.WithHeaders(
        Auth.StaticToken(Environment.GetEnvironmentVariable("OPENSHELL_TOKEN")!),
        new Dictionary<string, string> { ["x-team"] = "platform" }),
});

Plaintext and development settings

The SDK refuses to send credentials over plaintext http:// to a host other than loopback: the client throws OpenShellException with OpenShellErrorKind.InvalidConfig when it is created. Loopback addresses are allowed, which covers a local development gateway.

// A plaintext gateway on loopback: credentials may be sent to it without any opt-in.
var local = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("http://127.0.0.1:8080"),
    Auth = Auth.StaticToken("dev-token"),
});

// A gateway with a self-signed certificate: skip server certificate validation (development only).
var selfSigned = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://dev-gateway.internal:8443"),
    Tls = new TlsOptions { InsecureSkipVerify = true },
});

When you accept that the credential crosses the network in clear text, opt in with AllowInsecureAuth:

// Without AllowInsecureAuth this throws OpenShellException (InvalidConfig): the token would cross
// the network in clear text.
var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("http://gateway.lab.internal:8080"),
    Auth = Auth.StaticToken("lab-token"),
    AllowInsecureAuth = true,
});

Other checks made when the client is created: a client certificate must include its private key, and TlsOptions.InsecureSkipVerify disables server certificate validation, so use it only for local development. Validate runs the same checks without creating a client.