Skip to content

OAuth / OIDC

Tellstone plugs federated identity into its AUTH step. Instead of — or in addition to — a shared --require-pass password or per-user bcrypt hashes, a client can present an OpenID Connect (OIDC) id_token (a signed JWT) as its credential. The token’s claims are mapped to an RBAC role through the policy file’s oauth.rules, and that role is pinned to the connection exactly like a password-authenticated session.

OAuth is off by default and adds no overhead when disabled: with no --oauth-* flag configured the provider is nil, no code path changes, and AUTH behaves exactly as before.

OAuth requires RBAC: a token can only map to a role through the policy’s oauth.rules, so enabling it without a policy would silently deny every connection.

Both the RESP and binary listeners run the same dispatch on every AUTH:

  1. A credential that looks like a JWT (two dots, at least 5 bytes) is treated as a bearer token and routed to the token path; anything else goes through the normal password path.
  2. The token is verified off the event loop on the auth-worker pool: signature, issuer, audience, and expiry.
  3. The verified claims are mapped to a role via oauth.rulesfirst match wins.
  4. The role is pinned to the connection and subsequent commands are gated by the same permissions bitset as every other session.

The JWT shape check is deliberately shallow — a well-formed but forged token still fails verification. It exists purely to keep the password path fast, so a real password never needs a signature check.

If the auth-worker pool is saturated, the connection fails AUTH synchronously instead of stalling.

Terminal window
./bin/tellstone \
--rbac-config policy.yaml \
--oauth-provider google \
--oauth-client-id 1234-abc.apps.googleusercontent.com
FlagEnv varMeaning
--oauth-providerTSD_OAUTH_PROVIDERPreset name: google, stackit; empty + --oauth-issuer → generic OIDC
--oauth-issuerTSD_OAUTH_ISSUEROIDC discovery base URL of the identity provider
--oauth-client-idTSD_OAUTH_CLIENT_IDOAuth2 client ID used as the expected token audience

Startup rules:

  • No --oauth-* flag → provider is nil and password-only AUTH stays untouched.
  • --oauth-provider google / stackit → the preset constructor, which runs discovery eagerly.
  • --oauth-provider empty + --oauth-issuer set → generic OIDC.
  • Unknown provider name → startup error.
  • Provider set without --rbac-config → startup error (see above).
  • --oauth-client-id empty → startup error: an empty audience would skip aud validation.

There is no --oauth-client-secret by design. Verification is signature + issuer + audience based against the provider’s public JWKS, so no client secret is needed.

NameSelected byWhat it does
(generic)--oauth-issuer set, --oauth-provider emptyAny OIDC issuer. Runs discovery and caches the JWKS.
google--oauth-provider googleDefault issuer https://accounts.google.com; maps the hd (hosted-domain) claim into groups so a claim: groups rule works with one line.
stackit--oauth-provider stackitSTACKIT IAM preset with default issuer https://accounts.stackit.cloud.

--oauth-issuer is optional for the presets (they supply a default) and required for the generic provider.

The generic OIDC provider — the engine behind every preset — enforces:

  • Algorithm allowlist — only RS256 and ES256. The HS* family is rejected outright, preventing algorithm-confusion attacks.
  • kid required — the signing key is looked up unambiguously; a token without one is rejected rather than tried against every key.
  • Key rotation without restarts — a missed kid triggers exactly one JWKS refresh before giving up.
  • Temporal checksexp and nbf are enforced when present.
  • Issuer and audienceiss is compared against the configured issuer and aud against the client ID; aud may be a single string or a list (both are valid OIDC forms).
  • Fail-fast discovery — discovery and the initial JWKS fetch run at startup, so a wrong issuer or unreachable identity provider is reported before the first AUTH.

The RBAC policy file carries an oauth section. Each rule names a claim, a match pattern, and a target role; rules apply in file order and the first match wins:

policy.yaml
roles:
- name: admin
rules: ["+@all", "~*"]
- name: limited
rules: ["+get", "~*"]
oauth:
rules:
- claim: email
match: "*@tellstone.io" # trailing glob: any local-part at the domain
role: admin
- claim: groups
match: "admins" # exact value match
role: admin
- claim: sub
match: "*" # bare "*" matches any value
role: limited

Match patterns allow an exact value, a leading * glob (suffix match), or a trailing * glob (prefix match). A middle wildcard is rejected at policy load, so a typo surfaces before it silently denies access. Rules are compiled at load time — an unknown target role fails the load.

At AUTH time:

  1. The token’s claims are verified by the provider.
  2. oauth.rules are scanned in order; the first claim/value match yields the role.
  3. The connection’s identity (username / audit subject) is the token’s sub claim, falling back to "default".
  4. No match, or verification failure, means no role — and a session with no role is deny-all (fail-closed). The client receives the same ERR invalid password as a bad password, so nothing reveals that the IdP path exists.

AUTH with a single token argument routes to the token path:

> AUTH eyJhbGciOiJSUzI1NiIs...
+OK

The two-argument form is required: AUTH <username> <password> always treats the second argument as a password, never a token.

client.Auth sends the token in single-password mode (empty username):

c, err := client.Dial("127.0.0.1:9988", 2*time.Second)
if err != nil {
log.Fatal(err)
}
defer c.Close()
scratch := make([]byte, 64*1024)
if err := c.Auth("<id_token>", scratch); err != nil {
log.Fatal(err)
}
// connection is now authenticated — issue Get/Set/Delete as normal

Real id_tokens routinely exceed 1 KB, so the client falls back to a heap buffer when the credential overflows its 512-byte stack buffer.

A successful token AUTH is indistinguishable from a password AUTH downstream: the resolved role is pinned to the connection for its lifetime, subsequent commands are gated by the same permissions as every other session, and acl_deny / auth_* audit events carry the token’s sub as the user. A policy hot-reload (SIGHUP) never re-evaluates a pinned session.

Failure accounting is identical to the password path: failed attempts count against the per-connection limit (the listener closes the connection after the maximum), are recorded in the audit trail, and are surfaced by ACL LOG.

A ready-to-run policy and a walkthrough client ship in the main repository at cmd/example/oauth — start a server with --rbac-config cmd/example/oauth/policy.yaml --oauth-provider google --oauth-client-id <id>, then run the example with a token to watch it present the id_token and prove the pinned role.