Social SSO — Google + Discord
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Social SSO — Google + Discord
One-click agent sign-in via Google or RestoreHub Discord. BYOK client IDs; no platform credentials.
Beyond SAML/OIDC for enterprise IdPs, Chatly supports two consumer-grade social logins that need zero IdP setup on the customer side: Google and RestoreHub Discord. Both are agent- facing (logging into the dashboard, not the widget), both are BYOK at the platform-operator level, and both share the same backend endpoint shape.
Info — When to enable these
Google SSO is the quickest path for any team whose agents already use Google Workspace — one click and they're in. Discord SSO via RestoreHub is targeted at gaming, community, and creator-economy support teams whose ops happen inside Discord.
Warning — These are the only two social providers
There is no Microsoft, Apple, GitHub, or Slack agent login. (The OAuth providers listed under Integrations are a different surface — they connect third-party tools to a workspace, and none of them can sign anyone in.)
Google sign-in
Setup (operator side)
Create an OAuth 2.0 Client ID in Google Cloud Console:
Application type: Web application
Authorized JavaScript origins:
https://app.chatlychat.comhttp://localhost:5173(dev)
You don't need a redirect URI — the dashboard uses Google Identity Services'
credential(ID token) flow, not an authorization-code redirect.
Copy the Client ID (
123456789-abc.apps.googleusercontent.com).Set two env vars on the platform:
Parameters
Name | Type | Description |
|---|---|---|
|
| The OAuth client ID. Used to verify |
|
| Same value. Baked into the dashboard bundle at build time so the GSI script can initialize. |
Info — GOOGLE_CLIENT_SECRET is not part of this
It exists in the env schema and no code path reads it. Google sign-in verifies a signed ID token; there is no code exchange, so there is no secret to present. Calendar sync uses its own
GOOGLE_CALENDAR_CLIENT_ID/_SECRETpair.
Restart the dashboard + api containers. The login page surfaces the Google button automatically when VITE_GOOGLE_CLIENT_ID is non-empty.
Flow
Agent clicks the Google button on the login page.
Google Identity Services renders and handles the account chooser.
The browser receives a signed Google ID token (JWT) as
response.credential.Dashboard POSTs
{ idToken }to/v1/auth/google.Backend verifies the JWT against Google's JWKS, and checks
alg,iss,aud,exp,iat, andemail_verified.Upserts the user by email; mints Chatly access + refresh tokens.
POST /v1/auth/google — Public
{ "idToken": "eyJhbGciOiJSUzI1NiI..." }Returns the same AuthTokens shape as /v1/auth/login:
{
"accessToken": "...",
"refreshToken": "...",
"accessTokenExpiresAt": "...",
"refreshTokenExpiresAt": "...",
"meta": { "workspaceId": "01920f3c-...", "userId": "01920f3d-...", "created": false }
}created: true means we provisioned a brand-new user + workspace during this call (first-time Google login).
Security notes
Warning — Email-verified is non-negotiable
We reject tokens with
email_verified: false— and with the claim absent entirely. Google rarely issues those, but if a user has linked an unverified address we refuse the login rather than risk an account-takeover via a typo in someone else's Gmail.
Signature verification uses Google's published JWKS at
https://www.googleapis.com/oauth2/v3/certs. We cache for 1 hour.The JWT header's
algmust beRS256and it must carry akid.issmust beaccounts.google.comorhttps://accounts.google.com.audmust equalGOOGLE_CLIENT_IDexactly. It is compared as a single string — an ID token with a multi-valued audience is rejected.expandiatare checked with a 30-second clock-skew tolerance.
RestoreHub Discord sign-in
RestoreHub is a service that handles the Discord OAuth dance for you: agents click → Discord popup → success → RestoreHub returns a short-lived JWT that you trade for the user's Discord identity. Chatly uses this as a dashboard login path.
Info — Why a Discord SSO proxy and not direct OAuth
RestoreHub owns the OAuth app + client secret and handles token refresh through their proxy. The trade-off: an extra hop. The win: zero Discord-developer-portal setup on your side.
Setup (operator side)
Register your server at restorehub.net. Pick a slug (e.g.
chatly). The slug is public — commit it to source.In RestoreHub's Widget settings, add allowed origins:
https://app.chatlychat.comhttp://localhost:5173(dev)
Enable sharing Discord OAuth tokens with widget integrators. That is what unlocks the
/widget/access-tokenexchange below.Set two env vars on the platform:
Parameters
Name | Type | Description |
|---|---|---|
|
| Presence gate only. If it is unset, |
|
| Baked into the dashboard at build time so the RestoreHub widget can initialize against your server. |
Restart the dashboard + api. The Discord button appears on the login page automatically.
Flow
Agent clicks "Continue with Discord"
│
▼
RestoreHub widget script opens the Discord OAuth popup
│
▼
Popup posts back { token } (RestoreHub JWT)
│
▼
Dashboard POSTs /v1/auth/restorehub/discord
│
▼
Backend Path A: POST https://api.restorehub.net/api/v1/widget/access-token
├─ 200 + accessToken → GET https://discord.com/api/v10/users/@me
│ → verified email + global_name + avatar
├─ 401 → reject outright, no fallthrough
└─ anything else (403/404/5xx/network) → Path B
│
▼
Backend Path B: POST https://api.restorehub.net/api/v1/widget/verify-token
→ identity only: Discord user id, and email if RestoreHub has one
│
▼
Upsert user by email → mint Chatly tokensPOST /v1/auth/restorehub/discord — Public
{ "token": "eyJ...RestoreHub JWT..." }Returns the same AuthTokens shape as Google login.
Two-path token exchange — why
/widget/access-token gives us a Discord OAuth access token, which lets us call Discord's /users/@me and read the user's verified email, display name, and avatar. That's the happy path.
If it answers anything other than 200 or 401 — or the network call throws — we fall through to /widget/verify-token, which is identity-only: we get the Discord user id and, if RestoreHub holds one, an email. A 401 means the JWT is genuinely bad, so we reject rather than fall through.
The Discord id is validated as a snowflake (17–20 digits) before it is used for anything.
Discord without a verified email
Chatly requires an email on every user (users.email is NOT NULL). If neither path yields one — including the case where Discord reports the address as unverified — we return 422 with details.reason = "discord_no_email", so the dashboard can say "verify your email in Discord settings" rather than fabricate a placeholder address.
CSP
The dashboard's CSP is generated at container start by infra/docker/dashboard-entrypoint.sh from infra/docker/nginx.dashboard.conf.template. The social-login-relevant parts:
script-src 'self' 'unsafe-inline' https://accounts.google.com https://apis.google.com https://restorehub.net …;
connect-src 'self' {api} {ws} https://accounts.google.com https://restorehub.net https://api.restorehub.net …;
frame-src 'self' https://accounts.google.com https://restorehub.net;connect-src and frame-src are assembled from env vars — CSP_CONNECT_EXTRA and CSP_FRAME_EXTRA add origins without editing the template. Don't hand-edit the generated default.conf; it is overwritten on every start.
Common behaviour across both
Parameters
Name | Type | Description |
|---|---|---|
|
| Both providers link by email. If the address already has a live membership, the sign-in lands in the workspace of the oldest such membership — deterministic, so the same person lands in the same place every time. A soft-deleted membership does not count. |
|
| If the email has no live membership, we provision a new workspace with the user as owner. |
|
| Taken from the provider on each sign-in, but never over a name the user already set. |
|
| A social sign-in sets |
Danger — Social login does not enforce TOTP
Password login runs a two-factor challenge; these two endpoints mint session tokens directly. If an agent has TOTP enrolled, a Google or Discord sign-in bypasses it. Treat the social buttons as equivalent in strength to the provider account behind them, and leave them unset on a deployment where the second factor is load-bearing.
Warning — No dedicated rate limit
/v1/auth/login,/register, and the password-reset routes each carry their own throttle bucket. The two social endpoints carry none — only whatever limits your reverse proxy applies.
Combining with workspace-level SSO
You can run social SSO alongside workspace-scoped SAML/OIDC. There is no "require SSO for this workspace" setting to conflict with: every login path a deployment has enabled stays available. To force everyone through one path, unset the social env vars — that is the switch.
Which means, concretely:
Enabling SAML/OIDC for a workspace does not disable password login or the social buttons for its members.
Social sign-in is deployment-wide, not per-workspace. One workspace cannot turn it off for itself.
Troubleshooting
Warning — Google button stays hidden
VITE_GOOGLE_CLIENT_IDwasn't baked into the dashboard build. Vite inlines env vars at build time — re-run the dashboard build with the value set.
Warning — Discord popup completes but nothing happens
Either your origin isn't in RestoreHub's allow-list, or your CSP
connect-srcis blocking the callback. Check DevTools console — CSP violations are reported there.
Warning — 503 on POST /v1/auth/restorehub/discord
RESTOREHUB_SERVER_SLUGis unset on the API container. The dashboard-sideVITE_variable alone is not enough — both have to be set.
Info — 'aud mismatch' on Google verify
The
GOOGLE_CLIENT_IDon the api side and theVITE_GOOGLE_CLIENT_IDon the dashboard side don't match. They must be the same string — the backend rejects any token whoseaudclaim differs from what's configured.
Info — First-time login lands in an empty workspace
That's expected —
meta.created = truemeans we just provisioned the workspace. The dashboard should route to the onboarding wizard.