Keycloak-based authentication for Gryt, the voice, video and text chat platform.
One identity across every Gryt server, with token-based auth.
This repository is open source for transparency — you aren't expected to host this yourself.
Gryt uses a single auth service at auth.gryt.chat, and everyone authenticates through it whatever server they connect to. If anyone could run their own, they could mint an identity claiming to be anyone else, so the identities would stop meaning anything.
For a server host that means no auth setup: your server validates tokens against
auth.gryt.chat. For a user it means signing up once and being the same person
on every Gryt server. For a contributor it means this repo is here to be audited,
and the development setup below is only needed if you're working on auth
itself.
| Component | Description |
|---|---|
| Keycloak | Identity provider with custom Gryt realm and themes |
| Identity | Certificate authority that binds Keycloak identities to client public keys (details) |
| HAProxy | Reverse proxy for production TLS termination |
| Bootstrap | One-shot containers that bring a fresh Keycloak into a usable state (details) |
| Ops | Backup/restore tooling for the Postgres database |
Only needed if you're contributing to the auth service itself.
cp .env.example .env
./up.shStarts a local Keycloak instance via Docker Compose — completely separate from production.
Five one-shot containers run around the Keycloak server, in this order. All of them exit immediately and are safe to re-run.
| Service | What it does |
|---|---|
keycloak-pre-import-backup |
Dumps Postgres to ./backups before an import can overwrite anything. Only when GRYT_IMPORT_REALM=1. |
keycloak-import |
Imports realm/gryt-realm.json, substituting SMTP settings. Only when GRYT_IMPORT_REALM=1. --override true deletes the realm first — every user in it goes too. Refuses to run while Keycloak is up; stop it first. |
keycloak-bootstrap-admin |
Creates the master-realm admin if there isn't one. |
keycloak-user-profile |
Applies bootstrap/gryt-user-profile.json through the admin API, once the server is up. |
keycloak-security-policy |
Turns on brute-force protection and a password policy, through the admin API. |
Two things about this are worth knowing before you change any of it.
The import is an offline operation. kc.sh import rewrites the database directly, and a
running Keycloak goes on holding the realm id it started with — so importing underneath one
leaves it serving 500s until it's restarted, with every user in the realm already gone. It's
easy to do by accident, because docker compose up -d re-runs a one-shot whose config changed
without restarting keycloak, whose own definition didn't. import_realm.sh checks for a
listening server and refuses. To import deliberately:
docker compose -f docker-compose.keycloak.yml stop keycloak
docker compose -f docker-compose.keycloak.yml up -dThe admin isn't created by KC_BOOTSTRAP_ADMIN_* alone. Keycloak only does that
when start finds no master realm, and keycloak-import creates master as a side
effect of running first. On a genuinely fresh database that left a deployment with no
administrator at all, which is why keycloak-bootstrap-admin exists.
The user profile has to go through the admin API. A userProfile block in the
realm JSON is rejected on import and takes the whole stack down. Without the profile,
the realm falls back to Keycloak's built-in one, which requires firstName and
lastName — and since the realm sets registrationEmailAsUsername and the login theme
hides both fields, registration then fails on inputs nobody can see.
bootstrap/gryt-user-profile.json is the source of truth for what registration
collects. Editing the profile in the admin console works until the next time this runs.
docker compose up -d won't re-run a one-shot that has already exited, so
after a realm import you need up.sh, or:
docker compose -f docker-compose.keycloak.yml up -d --force-recreate --no-deps keycloak-user-profileLogin hardening is not in the realm file either. gryt-realm.json sets no
bruteForceProtected, no failureFactor and no passwordPolicy, so a stack brought up
without keycloak-security-policy accepts unlimited login attempts against any account
and whatever password a user picks. It runs through the admin API for the same reason the
user profile does, and it re-runs the same way:
docker compose -f docker-compose.keycloak.yml up -d --force-recreate --no-deps keycloak-security-policyThe password policy is checked when a password is set, never when one is used, so turning it on locks nobody out — existing passwords keep working until they are next changed. Lockouts are temporary rather than permanent, because a permanent one hands anybody who knows an email address a way to lock its owner out.
Most of it isn't in this repository.
A Cloudflare Managed Challenge on the registration path (GRYT-782). It judges the browser and the address it comes from, so it turns away crude automation. Someone driving a real browser through a residential proxy gets past it.
Email verification. verifyEmail is on, so every account needs a mailbox somebody can
read. That forces an attacker onto a throwaway-mail service or a domain they own. It's also
why blocking throwaway domains would help here.
Brute-force protection is about guessing a password, not making an account.
apply_security_policy.sh turns it on, and it does nothing for registration.
No captcha inside the Keycloak flow, and that's deliberate. Keycloak 26.5.3 ships two
captcha authenticators and both are Google's — RegistrationRecaptcha has
/recaptcha/api/siteverify written into it, with no way to point it somewhere else. So
Turnstile needs a Java authenticator rather than a setting. That's GRYT-790, and we haven't
built it on purpose. Google's would work today, and it would hand Google every registration.
If you do add one, both halves are things you do in the admin console, and a realm import
with --override true wipes them. Check them again afterwards.
Nothing rate-limits registration. Brute-force protection only covers logging in, and a Managed Challenge weighs reputation rather than volume. So nothing stops a slow trickle from ordinary-looking addresses. Fixing that means a Cloudflare rate-limiting rule on the registration path, not a change in here.
Three of them, and they are built three different ways because they are three different kinds of page.
| Theme | Where it lives | How it is built |
|---|---|---|
| login | login-theme/ |
Keycloakify, compiled into a jar. Real React, built from @gryt/ui so the sign-in page and the client share components rather than a look. |
themes/gryt/email/ |
Hand-written FreeMarker. Works, and Keycloakify's email support is a separate job. | |
| account | themes/gryt/account/ |
Keycloak's own console with Gryt's palette on it. |
The account theme is the odd one, so here is why it is not a Keycloakify theme like the
login one. @keycloakify/keycloak-account-ui peers on React 18 and PatternFly 5, and
login-theme/ is on React 19 — and every published line of that package wants React 18,
including the one built for Keycloak 26.7, so a Keycloak upgrade would not resolve it.
Painting the console we already serve turned out to be the better trade anyway. Every colour
in PatternFly resolves from one table of custom properties, so remapping that table is the
whole theme: no component is restyled, and a Keycloak upgrade that changes a component
cannot break it. themes/gryt/account/resources/css/account.css is that table, and its
palette comes from @gryt/ui through npm run account-tokens rather than being restated.
The console is dark whatever the browser is set to, matching the login page it is reached from.
Changing accountTheme in realm/gryt-realm.json does not change a running Keycloak.
Realm JSON is read on a fresh import only. On a server that is already up, set it in the
admin console under Realm settings → Themes, or with kcadm.sh update realms/gryt -s accountTheme=gryt.
See the architecture overview for how auth fits into the Gryt platform.
Please report bugs and request features in the main Gryt repository.
What sponsoring pays for, the tiers, and everyone who has sponsored: gryt.chat/sponsors. To sponsor: GitHub Sponsors.
The list itself lives in the Gryt README, in one place rather than ten, so it cannot fall out of step across repositories.