Connect an Identity Provider
With audience = "sso", a gated Cell hands sign-in to an identity provider: visitors authenticate there, and the gate turns the provider's assertion into an ordinary Cell session. This page is the provider-by-provider setup — what to configure in each console, the ribo.toml block, and where the client secret goes. The policy model itself (audiences, domains/allow, sessions, the headers your Cell reads) is on the Access Control (Gate) page.
A policy holds up to 8 [gate.source.<name>] tables. The hosted sign-in page shows one button per source, labeled by its label key or, absent that, the source name.
What any provider needs
Tissue speaks standard OpenID Connect, so any compliant provider works, hosted or self-hosted. In provider terms, the requirements are:
- A discovery document at
<issuer>/.well-known/openid-configuration. Theissuervalue inside it must equal the issuer you configure — the gate checks this, and it is the most common thing to get wrong. To catch it before you deploy, open<issuer>/.well-known/openid-configurationin a browser and confirm its"issuer"field is character-for-character what you put inribo.toml, trailing slash included. - Authorization-code flow with PKCE (S256). No implicit flow, no device flow.
- An email claim. The gate requests
openid email profileby default; the ID token must carry a non-emptyemail, andemail_verified: falsefrom the provider is a denial. client_secret_posttoken authentication — the client id and secret travel in the token-request body. Every provider below supports it.
Register a server-side client, not a browser one
Every console asks what kind of client you are registering, and several have a second field for browser origins beside the redirect URI. Answer both the same way everywhere: the gate is a confidential, server-side client. The visitor's browser only follows redirects; the code exchange runs on Tissue's servers, with the client secret, from an address the provider never sees. No JavaScript on your Cell's pages ever calls the provider, so there is no origin to authorize and no implicit or SPA flow to enable.
| Provider | Client type | Fill in | Leave empty or off |
|---|---|---|---|
| Web application | Authorized redirect URIs | Authorized JavaScript origins | |
| GitHub | OAuth App (not a GitHub App) | Authorization callback URL | Enable Device Flow |
| Authentik | OAuth2/OpenID Provider, Confidential | Redirect URI, matching mode Strict | — |
| Keycloak | OpenID Connect, Client authentication on | Valid redirect URIs | Web origins, Implicit flow, Direct access grants |
| Pocket ID | OIDC client (not a public client) | Callback URL | — |
| Microsoft Entra ID | Platform Web (never Single-page application) | Redirect URI | Implicit grant checkboxes, Allow public client flows |
Leaving the browser fields empty costs nothing: a filled-in origin list does not break sign-in either, it is simply never consulted. Picking the browser client type does break it — a provider that thinks it is talking to a public client refuses the secret the gate sends.
Two Tissue-side constants appear in every walkthrough.
The redirect URI. Register this with the provider, once per hostname the Cell serves on:
https://<cell>.<sub>.tissue.dev/.tissue/gate/oidc/<source>/callbackThere is nothing to invent here. <cell>.<sub>.tissue.dev is the Cell's own address — the host a visitor sees in the address bar when they open it, printed on the url line of ribo deploy, listed in the dashboard, and shown at the top of the gate's own sign-in page. <source> is the [gate.source.<name>] table name (a source named keycloak gives .../oidc/keycloak/callback), and the /.tissue/gate/oidc/… path is reserved on every Cell host — you never route it.
Register it once per hostname because the gate constructs the redirect URI from the host the visitor actually arrived on and sends the provider that exact string. A Cell reached through a custom domain sends the visitor back through the same domain, so that hostname needs its own registration. Providers compare the value literally, down to the scheme and the trailing path.
The client secret. It never goes in ribo.toml. Store it in the Cell's vault under GATE_OIDC_<NAME> — the source name uppercased, with - replaced by _ (so [gate.source.my-idp] reads its secret from GATE_OIDC_MY_IDP):
ribo vault set my-cell GATE_OIDC_OKTA # prompts for the secretSet it before ribo deploy; a deploy whose sources are missing their secrets fails with the exact command to run.
Google sign-in uses an OAuth client from the Google Cloud console. There is no separate step that issues a client id: Google mints the id and the secret together when you create the client, on one screen.
In the Google Cloud console, pick or create a project, then open APIs & Services → Credentials. Consoles moved to the newer Google Auth Platform show the same screens under Google Auth Platform → Clients. Nothing needs enabling under Library — sign-in is not one of the APIs you turn on.
First-time setup asks for an OAuth consent screen (Branding and Audience in the newer console): app name, user-support address, developer contact. Choose Internal if every visitor is in your own Google Workspace organization; otherwise External, and read the publishing note below. The gate runs its own membership check after Google's sign-in either way.
Create credentials → OAuth client ID, application type Web application. The client's name is console-side only — visitors never see it.
Under Authorized redirect URIs, click + Add URI and paste your Cell's callback from the top of this page, with
googleas the source name:https://<cell>.<sub>.tissue.dev/.tissue/gate/oidc/google/callbackLeave Authorized JavaScript origins empty. That field authorizes pages that call Google from browser JavaScript; the gate never does — see Register a server-side client. Only the redirect URI matters here, and it is the field to check first when a sign-in fails.
Create. Google shows Your Client ID and Your Client secret in a panel, with a Download JSON button. The client id ends in
.apps.googleusercontent.comand stays readable on the client's page for good — that is the value forribo.toml, and you can come back for it. The secret is the half to capture now: recent consoles show it only at creation. If you lose it, add a new secret on the client's page and re-runribo vault set; the old one keeps working until you delete it.
[gate]
audience = "sso"
domains = ["yourco.com"]
[gate.source.google]
type = "google" # preset — issuer https://accounts.google.com implied
client_id = "1234-abc.apps.googleusercontent.com"ribo vault set my-cell GATE_OIDC_GOOGLEA google source with no domains and no allow is rejected at deploy time: it would admit anyone with a Google account. List the domains or addresses that belong in.
An External app in Testing admits only its test users. A new External app starts there, and Google refuses everyone else at its own sign-in screen — before the gate is reached, so nothing in your policy changes the outcome — while listed testers get an "unverified app" interstitial. Publish app clears both, and clears them immediately: the gate asks for openid email profile, all non-sensitive scopes, so publishing does not queue the app for Google's verification review. Internal apps never enter Testing.
GitHub
GitHub sign-in uses an OAuth App — not a GitHub App. (A GitHub App is the installable kind with repository permissions; the gate needs only identity, which is the OAuth App's job.)
- On GitHub, open Settings → Developer settings → OAuth Apps → New OAuth App. Registering it under the organization your visitors belong to (Organization settings → Developer settings) lets any owner rotate the secret later; under a personal account, only that account can.
- Application name is what GitHub shows visitors on its authorization screen. Homepage URL is required by the form and unused by the gate — your Cell's URL is the honest answer. Leave Enable Device Flow unchecked.
- Set Authorization callback URL to your Cell's callback, with
githubas the source name. An OAuth App holds exactly one, and GitHub matches its host exactly, so register the hostname visitors actually use: a Cell reached through a custom domain cannot also sign visitors in on itstissue.devhost from the same app. - Register application. The Client ID is on the app's page and stays there. Generate a new client secret for the other half — GitHub stores only a hash and shows the value once; if it scrolls away, generate another and re-run
ribo vault set.
[gate]
audience = "sso"
allow = ["ann@example.com", "bob@example.com"]
[gate.source.github]
type = "github" # preset — plain OAuth2 against github.com, NOT OIDC
client_id = "9a1f0c2b7d4e6a8c0135"ribo vault set my-cell GATE_OIDC_GITHUBGitHub is plain OAuth2 rather than OIDC; the gate asks for read:user user:email, reads the account's primary, verified email through the GitHub API, and applies the domains/allow check to it. If an organization restricts third-party OAuth application access, an owner has to approve the app before its members can sign in. A visitor whose GitHub account has no verified email is asked to verify one on GitHub first. GitHub sources supply no groups. As with google, a policy with domains and allow both empty is rejected at deploy time.
Authentik
- In the Authentik admin interface, open Applications → Providers → Create and pick OAuth2/OpenID Provider: client type Confidential, with your Cell's callback as the Redirect URI and matching mode Strict. Versions that take a list accept one entry per hostname; regex mode buys nothing here.
- Create an Application bound to that provider (Applications → Create).
- The provider's protocol settings hold both credentials: Client ID is generated when you save, Client Secret sits under it, and Authentik keeps showing both — unlike Google or GitHub, you can come back for the secret.
- The provider's overview page shows the issuer — a URL like
https://auth.example.com/application/o/my-cell/. The trailing slash is part of it; configure it exactly as shown.
[gate]
audience = "sso"
[gate.source.authentik]
type = "oidc"
issuer = "https://auth.example.com/application/o/my-cell/"
client_id = "..."
label = "Acme sign-in"ribo vault set my-cell GATE_OIDC_AUTHENTIKAuthentik's default profile scope already includes a groups claim, so group names reach your Cell with the default scopes — nothing extra to map.
Keycloak
- In the Keycloak admin console, pick your realm and open Clients → Create client: type OpenID Connect, with Client authentication turned on (that makes it a confidential client with a secret). The Client ID you type here is the
client_idvalue — Keycloak does not generate one. On the capability screen leave Standard flow on and the rest off — Direct access grants and Implicit flow belong to other kinds of client. - Set Valid redirect URIs to your Cell's callback —
https://<cell>.<sub>.tissue.dev/.tissue/gate/oidc/keycloak/callback, the redirect URI from the top of this page withkeycloakas the source name. Keycloak also accepts a path wildcard,https://<cell>.<sub>.tissue.dev/*, if you would rather not pin the exact path; add one entry per hostname the Cell serves on. - Leave Web origins empty. It is Keycloak's version of Google's JavaScript origins — a CORS allow-list for browser clients, which the gate is not. Filling it in is harmless;
*in it is a habit worth not forming. - The secret is on the client's Credentials tab, readable whenever you need it, with Regenerate beside it.
- The issuer is
https://<keycloak-host>/realms/<realm>— the realm's OpenID Endpoint Configuration link shows it.
[gate]
audience = "sso"
[gate.source.keycloak]
type = "oidc"
issuer = "https://sso.example.com/realms/acme"
client_id = "my-cell"ribo vault set my-cell GATE_OIDC_KEYCLOAKGroups need a client-scope mapper — Keycloak does not put group membership in tokens by default. On the client, open the Client scopes tab, pick the client's dedicated scope, and Add mapper → By configuration → Group Membership. Set the token claim name to groups and turn Full group path off, so names arrive as ops rather than /ops.
Pocket ID
Pocket ID is a small self-hosted OIDC provider built around passkeys — visitors sign in with a passkey rather than a password, and the gate consumes the result like any other issuer.
- In the Pocket ID admin, add an OIDC client and set its callback URL to your Cell's callback. Do not mark it a public client — a public client has no secret, and the gate authenticates with one.
- Copy the client id and client secret from the panel Pocket ID shows on save. Regenerate the secret from the client's page if it gets away from you, then re-run
ribo vault set. - The issuer is your Pocket ID base URL — the bare origin, with no realm or application path appended (unlike Authentik and Keycloak, whose issuers carry a path).
[gate]
audience = "sso"
[gate.source.pocketid]
type = "oidc"
issuer = "https://id.example.com"
client_id = "..."ribo vault set my-cell GATE_OIDC_POCKETIDTo pass user groups through to your Cell, add groups to the requested scopes: scopes = "openid email profile groups".
Microsoft Entra ID
Entra ID's free tier covers OIDC sign-in — no premium license needed for this.
- In the Microsoft Entra admin center, open App registrations → New registration. Pick the single-tenant account type, then add a redirect URI under the Web platform — not Single-page application, which registers the same string as a public browser client and makes Entra reject the gate's secret at the token request.
- The Application (client) ID on the overview page is your
client_id; the Directory (tenant) ID beside it goes into the issuer. Both stay readable there — neither is shown once. - Under Certificates & secrets, create a New client secret and copy its Value column, shown once and never again. The Secret ID beside it is a handle for the credential, not the credential; a sign-in that fails with an invalid-client error usually means the id went into the vault.
- Under Authentication, leave the implicit-grant checkboxes (Access tokens, ID tokens) unticked and Allow public client flows set to No.
Use the tenant-specific issuer. The common endpoint's discovery document advertises a templated issuer that can never match a configured one, so the gate rejects it. Configure the tenant-id form:
[gate]
audience = "sso"
[gate.source.entra]
type = "oidc"
issuer = "https://login.microsoftonline.com/<tenant-id>/v2.0"
client_id = "..."
label = "Acme"ribo vault set my-cell GATE_OIDC_ENTRATwo Entra-specific claim notes:
- Email. Some tenants omit the
emailclaim from ID tokens. If sign-ins are denied with no address shown, addemailas an optional claim (Token configuration → Add optional claim → ID → email) and confirm the user has an email set on their profile. Entra's v2 tokens omitemail_verifiedentirely; the gate accepts an absent claim — it refuses only a value that explicitly says the address is unverified. - Groups arrive as GUIDs. Add the claim under Token configuration → Add groups claim; unless your tenant is directory-synced and configured to emit names, the values are group object IDs, and that is what your Cell receives in
x-tissue-gate-groups.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The provider shows a redirect URI mismatch error | The registered URI differs from https://<hostname>/.tissue/gate/oidc/<source>/callback for the hostname the visitor used. Register the exact URI — scheme, host, and path — once per hostname, custom domains included. |
| Google refuses before the gate is reached: Access blocked, or a screen about the app not being verified | The OAuth client's consent screen is External and still in Testing. Add the address under Test users, or Publish app — the gate's scopes are non-sensitive, so publishing needs no Google review. |
| Entra returns AADSTS9002326, cross-origin token redemption | The redirect URI is registered under the Single-page application platform. Remove it there and add it under Web. |
| Sign-in succeeds at the provider, then the gate denies with an unverified email | OIDC: the provider sent email_verified: false. GitHub: the account has no verified email — verify one on GitHub and retry. |
A configuration-error page names a ribo vault set command |
The client secret is missing from the Cell's vault, or was deployed as a placeholder. Run the command it names; the fix takes effect on the next sign-in, no redeploy needed. |
| Deploy or sign-in fails with an issuer mismatch | The issuer in the provider's discovery document must equal the configured one. Entra: use the tenant-specific issuer, never common. Authentik: keep the trailing slash exactly as the provider prints it. |
x-tissue-gate-groups is absent or empty |
The provider sent no usable groups claim. Request the right scope (scopes = "openid email profile groups" where the provider expects it), add the mapper (Keycloak) or token claim (Entra), and check that groups_claim names the claim the provider actually emits. The claim must be a JSON array of strings. |
Whichever provider you connect, the secret's custody is the same: the client secret is stored encrypted in the Cell's vault, is read only when a visitor signs in, and never appears in ribo.toml, a deploy, or the dashboard.