Setting up SSO
For a first deployment, start with Register apps and enable signup. It walks through one recommended sign-in provider, then tenant-owned chat apps and Gauzy pairings.
This is the page to have open while you create the OAuth applications. Every URI,
scope and key below is read out of the code that will consume it
(crates/plugins/auth-oauth, crates/server/src/oauth.rs), not from a provider's
marketing page — so what you paste into a developer console and what Ever Async
sends are the same string.
Budget about twenty minutes for all five. Slack and Google take the longest; Discord and GitHub are three fields each.
Roughly every first SSO attempt that fails, fails here — and each provider
reports it differently, in its own words, on its own error page, before the
browser ever comes back to Ever Async. So you will not see an Ever Async error
message at all: you will be stranded on accounts.google.com or slack.com
wondering what happened.
The URI must match byte for byte: the same scheme (https vs http), the
same host, the same port, the same path, the same case, and no trailing
slash. https://app-async.ever.co/api/v1/auth/oauth/google/callback/ — one
extra character — is a different URI to every provider on this page.
The ten redirect URIs
OAuthProvider::callback_url builds exactly one string per provider:
{[auth.providers.oauth] base_url}/api/v1/auth/oauth/{provider-id}/callback
base_url has any trailing slash trimmed when it is read, and the provider id is
the lowercase registry id (slack, discord, github, google, gauzy). That
gives, for the two hosts you will actually register:
| Provider | Production — base_url = "https://app-async.ever.co" | Local dev — base_url = "http://localhost:8100" |
|---|---|---|
| Slack | https://app-async.ever.co/api/v1/auth/oauth/slack/callback | http://localhost:8100/api/v1/auth/oauth/slack/callback |
| Discord | https://app-async.ever.co/api/v1/auth/oauth/discord/callback | http://localhost:8100/api/v1/auth/oauth/discord/callback |
| GitHub | https://app-async.ever.co/api/v1/auth/oauth/github/callback | http://localhost:8100/api/v1/auth/oauth/github/callback |
https://app-async.ever.co/api/v1/auth/oauth/google/callback | http://localhost:8100/api/v1/auth/oauth/google/callback | |
| Ever Gauzy | https://app-async.ever.co/api/v1/auth/oauth/gauzy/callback | http://localhost:8100/api/v1/auth/oauth/gauzy/callback |
Two consequences worth internalising before you start:
- It is checked twice. The provider checks the
redirect_uriyou send against the one you registered. Then, when the code comes back, Ever Async re-checks it in constant time against the URI the login started with, and refuses a code redeemed against any other. Neither check is fuzzy. - Two providers will not take
http://localhostat all — Slack requires HTTPS redirect URLs, and a self-hosted Ever Gauzy is whatever you point it at. See each provider's section.
Once the config is in place, GET /api/v1/auth/mode lists exactly which
providers are live. Getting the callback wrong is not something the server can
detect for you — only the provider can — so copy from the table, do not retype.
Before you open a single console
Three decisions, made once, that every section below assumes.
-
Pick
base_url. The public origin of the Ever Async API and dashboard host —https://app-async.ever.coin production. It is not the docs host and not the marketing site. Everything is derived from it. -
Decide
allowed_domains. Read the warning below now, not after the first stranger signs up. -
Have the environment variable names ready. No client secret is ever written into
everasync.toml; the file names a variable and the variable holds the value — the*_envrule. The names used throughout this page are the oneseverasync initalready writes:EVERASYNC_SLACK_CLIENT_SECRET
EVERASYNC_DISCORD_CLIENT_SECRET
EVERASYNC_GITHUB_CLIENT_SECRET
EVERASYNC_GOOGLE_CLIENT_SECRET
EVERASYNC_GAUZY_CLIENT_SECRET
Slack
Slack is the provider worth doing first: it is the only one of the five that tells Ever Async which workspace the person signed in from, which is what lets a whole team land in one tenant with no picker.
1. Where to create the app
https://api.slack.com/apps → Create New App → From scratch. Name it, pick the workspace you develop in, create.
If you already have a Slack app for the bot side of Ever Async (Event
Subscriptions, the /async command), use that same app — see the trap below.
2. The redirect URI to paste
OAuth & Permissions → Redirect URLs → Add New Redirect URL:
https://app-async.ever.co/api/v1/auth/oauth/slack/callback
Then Add → Save URLs. The Save button is separate from Add; a URL that is added but not saved does not exist.
Slack rejects http:// redirect URLs outright, so
http://localhost:8100/api/v1/auth/oauth/slack/callback cannot be registered.
For local development, put a tunnel (ngrok, Cloudflare Tunnel) in front of
:8100, set base_url to the tunnel's origin, and register
https://<tunnel-host>/api/v1/auth/oauth/slack/callback. The tunnel host is now
part of the byte-for-byte match, and it changes every time an ephemeral tunnel
restarts.
3. The scopes the code requests
ProviderKind::Slack.scope() is exactly:
openid,email,profile
Add all three under OAuth & Permissions → Scopes → User Token Scopes
(not Bot Token Scopes). Those three user scopes are the "Sign in with Slack"
product; without them https://slack.com/openid/connect/authorize will not issue
a code you can redeem.
Slack is the only provider here whose OIDC endpoints want a comma-separated
scope string. Every other provider on this page takes spaces. When you see
scope=openid%2Cemail%2Cprofile in the authorize URL, that is correct.
4. What to copy back
Basic Information → App Credentials: Client ID and Client Secret
(Show). The Signing Secret on that same panel is a different thing — it belongs
to [channels.slack] signing_secret_env and verifies inbound webhooks.
[auth.providers.oauth.slack]
client_id = "1234567890.1234567890"
client_secret_env = "EVERASYNC_SLACK_CLIENT_SECRET"
export EVERASYNC_SLACK_CLIENT_SECRET="…"
5. Traps
"Sign in with Slack" and "Add to Slack" are two different products, and you want both. They live in the same Slack app and they are not substitutes:
| "Sign in with Slack" | The bot install ("Add to Slack") | |
|---|---|---|
| What it is | OpenID Connect identity — who is this person, what workspace | An installed bot user with a xoxb- token |
| Configured by | The three user scopes above | Bot token scopes (chat:write, history scopes) + Event Subscriptions |
| Token | Used once by auth-oauth to read the profile, then dropped | Long-lived, held by [channels.slack] |
| Gives you | A login button | A bot that sees messages and delivers nudges |
auth-oauth deliberately implements only the sign-in half — none of its
scopes let Ever Async read or post anything. So SSO alone gives you a login and a
silent channel; the bot alone gives you nudges and no way to sign in. For the
full experience, configure both on the one app. The bot side is
Channels → Slack.
Slack answers failures with HTTP 200. openid.connect.token returns
{"ok": false, "error": "…"} with a 200 status. The crate ignores the status and
keys off the presence of access_token, and puts Slack's own error code into the
debug log — so RUST_LOG=…ever_async=debug is where a Slack-side problem is
legible.
No PKCE. supports_pkce() is false for Slack; a code_challenge is not
sent. That is deliberate — this is a confidential client with a secret, and the
single-use state is what defeats replay.
A missing team id is survivable but silent. If Slack's userinfo comes back
without the https://slack.com/team_id claim, the login still succeeds, but it
cannot resolve a workspace binding on its own. The crate logs a warn saying so.
6. If it fails
| What you see | What it was | Code at /#error= |
|---|---|---|
| A Slack error page, never returning to Ever Async | Redirect URL not registered, not saved, or http:// | (none — the flow never came back) |
"Sign-in did not complete — unauthorized" | Slack sent email_verified: false, or the domain is outside allowed_domains, or the code/state was refused | unauthorized |
"Sign-in did not complete — access_denied" | The person clicked Cancel on Slack's consent screen | access_denied |
Discord
1. Where to create the app
https://discord.com/developers/applications → New Application. Name it, accept the developer terms, create.
2. The redirect URI to paste
Left nav → OAuth2 → Redirects → Add Redirect:
https://app-async.ever.co/api/v1/auth/oauth/discord/callback
and, if you develop locally, a second entry:
http://localhost:8100/api/v1/auth/oauth/discord/callback
Then Save Changes (the green bar at the bottom). Discord accepts several
redirects per application and http://localhost, so one app covers both hosts.
3. The scopes the code requests
ProviderKind::Discord.scope() is exactly:
identify email
Space-separated, and nothing else. identify yields the user id; email yields
the address and its verified flag. There is nothing to tick in the console —
Discord takes the scopes from the authorize URL — but the OAuth2 URL Generator
on that page is a convenience you do not need: Ever Async builds the URL itself.
4. What to copy back
Same OAuth2 page: Client ID, and Client Secret via Reset Secret (Discord shows a secret once).
[auth.providers.oauth.discord]
client_id = "0123456789012345678"
client_secret_env = "EVERASYNC_DISCORD_CLIENT_SECRET"
export EVERASYNC_DISCORD_CLIENT_SECRET="…"
5. Traps
identify email — both, always. Without email the profile carries no
address at all and map_profile refuses the login ("profile carried no email
address"); without identify there is no stable subject to key the account off.
The code always sends both, so the trap here is only for anyone hand-editing the
scope string.
Discord's verification flag is called verified, not email_verified. An
account that never confirmed its address has verified: false, and Ever Async
rejects it — same rule as everywhere else on this page. The user's fix is one
click in a mail Discord already sent them.
Discord logins carry no workspace. A Discord guild is bound by a bot install, not by a personal login: the profile says nothing about which guilds someone is in. So a Discord sign-in always falls through to the ordinary tenant rules — an existing membership, then a fresh tenant of their own.
PKCE is on. Discord is one of the two providers where supports_pkce() is
true, so the authorize URL carries code_challenge + code_challenge_method=S256.
Nothing to configure; it is defence in depth for a leaked authorization code.
6. If it fails
| What you see | What it was | Code at /#error= |
|---|---|---|
| Discord's own "Invalid OAuth2 redirect_uri" page | The URI is not in the Redirects list, or Save Changes was never clicked | (none) |
"Sign-in did not complete — unauthorized" | verified: false on the Discord account, or the domain is outside allowed_domains | unauthorized |
"Sign-in did not complete — access_denied" | Cancel on Discord's authorize screen | access_denied |
GitHub
1. Where to create the app
Create an OAuth App, not a GitHub App — the code targets
https://github.com/login/oauth/authorize and
https://github.com/login/oauth/access_token.
- Personal: https://github.com/settings/developers → OAuth Apps → New OAuth App
- Organization:
https://github.com/organizations/<ORG>/settings/applications→ New OAuth App
An org-owned app survives a person leaving; a personal one does not. For
app-async.ever.co, own it in the org.
2. The redirect URI to paste
The field is called Authorization callback URL:
https://app-async.ever.co/api/v1/auth/oauth/github/callback
GitHub's current registration guide
supports up to ten callback URLs. Separate OAuth applications for dev, stage,
and production still allow independent credential rotation and revocation.
For a local application, register
http://localhost:8100/api/v1/auth/oauth/github/callback and use that
application's Client ID and secret in the local server configuration.
3. The scopes the code requests
ProviderKind::GitHub.scope() is exactly:
read:user user:email
read:user for GET https://api.github.com/user; user:email for
GET https://api.github.com/user/emails, which
is the only place a verified address can be read. There is no scope picker on
an OAuth App — GitHub takes them from the authorize URL and shows them on the
consent screen.
4. What to copy back
The app's page: Client ID, then Generate a new client secret → copy it immediately (GitHub shows it once and masks it afterwards).
[auth.providers.oauth.github]
client_id = "Ov23li0123456789abcd"
client_secret_env = "EVERASYNC_GITHUB_CLIENT_SECRET"
export EVERASYNC_GITHUB_CLIENT_SECRET="…"
5. Traps
GitHub is the one provider where the profile's email is not the identity.
GET https://api.github.com/user returns a public profile address, which may be a
…@users.noreply.github.com alias, may be null, and carries no verification
flag at all. So the crate makes a second call to /user/emails and takes the one
entry that is both primary and verified — strictly both:
- primary but unverified → refused (it is just a string someone typed);
- verified but not primary → refused (which address won would otherwise depend on array order).
If an account has no verified primary address, the login is refused with
"no primary verified email on the account (an unverified address is not an
identity)" in the debug log. The user's fix is
github.com/settings/emails → verify the
primary address. There is nothing to change on your side.
The user:email scope is not optional cosmetics. Drop it and /user/emails
answers 403, the second call fails, and every GitHub login is refused.
Current PKCE default. Async's GitHub supports_pkce() is false.
The implemented default uses a confidential client secret and single-use state;
this describes the current Async configuration, not a claim about all GitHub
OAuth capabilities.
GitHub reports a bad code with HTTP 200, as {"error": "bad_verification_code"}.
Authorization codes are single-use and short-lived; a refreshed callback URL is
already spent. The crate logs GitHub's error code at debug.
Numeric ids are fine. GitHub sends id as a JSON number; it becomes the
string "github:1234567" as the Ever Async user id. Nothing to do.
6. If it fails
| What you see | What it was | Code at /#error= |
|---|---|---|
| GitHub: "The redirect_uri MUST match the registered callback URL for this application." | The Authorization callback URL is wrong, or you are pointing local dev at the production app | (none) |
"Sign-in did not complete — unauthorized" | No primary + verified email on the account, or user:email was stripped, or the domain is outside allowed_domains | unauthorized |
"Sign-in did not complete — access_denied" | Cancel on GitHub's authorize screen | access_denied |
Google
1. Where to create the app
Two steps in Google Cloud, in this order, in one project:
- Consent screen — https://console.cloud.google.com/apis/credentials/consent. Choose Internal (a Workspace org — only your own domain can sign in) or External (anyone with a Google account). Fill in app name, support email, developer contact.
- Credentials — https://console.cloud.google.com/apis/credentials → Create Credentials → OAuth client ID → Application type Web application.
2. The redirect URI to paste
Under Authorized redirect URIs → Add URI:
https://app-async.ever.co/api/v1/auth/oauth/google/callback
and, for local development, a second entry — Google is the one provider that exempts loopback from its HTTPS rule:
http://localhost:8100/api/v1/auth/oauth/google/callback
Leave it empty. It applies to browser-side implicit flows; Ever Async runs a server-side authorization-code exchange and never touches a Google token in the browser. Filling it in does nothing, and leaving it empty breaks nothing.
Also: http://localhost:8100 and http://127.0.0.1:8100 are different
strings to Google. Register the one you actually browse to.
3. The scopes the code requests
ProviderKind::Google.scope() is exactly:
openid email profile
Space-separated. All three are Google's non-sensitive scopes, which is why this integration needs no Google verification review to be published — the app can go from Testing to In production without a security assessment.
4. What to copy back
The dialog shows Client ID and Client secret once at creation, and both remain readable on the client's detail page afterwards.
[auth.providers.oauth.google]
client_id = "1234567890-abcdef.apps.googleusercontent.com"
client_secret_env = "EVERASYNC_GOOGLE_CLIENT_SECRET"
export EVERASYNC_GOOGLE_CLIENT_SECRET="…"
5. Traps
The publishing status is the trap that bites hardest. A newly created External consent screen sits in Testing, where only accounts on the Test users list can complete a login — everyone else is stopped by Google with "Access blocked: … has not completed the Google verification process", which reads like a code problem and is not one. Either add every early user as a test user, or move the app to In production (safe here, given only non-sensitive scopes).
Google rejects email_verified: false. The flag must be affirmatively true;
absent counts as unverified, never as "probably fine". Google has historically
sent it as the string "true", which the code accepts. Consumer and Workspace
accounts normally arrive verified, so this fires mainly for oddly-provisioned
accounts.
A Google login carries no workspace. Google's userinfo asserts no
organization id that Ever Async binds to, so a Google sign-in falls through to
the ordinary tenant rules. hd-based domain mapping is not implemented — use
allowed_domains for the "only my company" behaviour, or Internal on the
consent screen.
PKCE is on. supports_pkce() is true for Google; nothing to configure.
6. If it fails
| What you see | What it was | Code at /#error= |
|---|---|---|
| Google: "Error 400: redirect_uri_mismatch" (with the URI it received) | Not in Authorized redirect URIs, or wrong scheme/port/trailing slash. Google prints the exact string it got — diff it against the table above | (none) |
| Google: "Access blocked: … has not completed the Google verification process" | Consent screen still in Testing, signer not a test user | (none) |
"Sign-in did not complete — unauthorized" | email_verified not true, or the domain is outside allowed_domains | unauthorized |
"Sign-in did not complete — access_denied" | Cancel on Google's consent screen | access_denied |
Ever Gauzy
Gauzy has no developer console. Registration is one authenticated POST against
Gauzy's own oauth_clients registry, run by a Gauzy SUPER_ADMIN or ADMIN
holding the OAUTH_CLIENT_EDIT permission.
1. Where to create the app
Ever Gauzy SSO → Register Ever Async in Gauzy
carries the full curl, every field of CreateOAuthClientDTO, and why the flow
is shaped the way it is. It is not duplicated here — run it from there.
The shape of it: POST https://api.gauzy.co/api/oauth/clients with
clientType: "confidential", allowedGrantTypes: ["authorization_code"],
allowedScopes: ["profile", "email"], pkceRequired: false.
2. The redirect URI to paste
redirectUris is a JSON array in that request body, and it must contain the URI
character for character:
https://app-async.ever.co/api/v1/auth/oauth/gauzy/callback
For a local Ever Async against Ever's hosted Gauzy, register a second entry in the same array:
http://localhost:8100/api/v1/auth/oauth/gauzy/callback
Gauzy validates redirect_uri at both /authorize and /token, and
Ever Async re-checks it a third time when the code comes back.
3. The scopes the code requests
ProviderKind::Gauzy.scope() is exactly:
profile email
Space-separated, and the same two strings you put in allowedScopes. Gauzy does
not currently narrow a token by scope, so this is a statement of intent shown on
its consent screen rather than an enforced ceiling — which is one more reason
the access token is read once and dropped rather than kept.
4. What to copy back
The create response returns the plaintext clientSecret exactly once; there
is no way to read it back. It goes straight into the environment variable and
nowhere else — not into a note, not into the TOML file.
[auth.providers.oauth.gauzy]
base_url = "https://api.gauzy.co" # optional — this is the default
client_id = "gauzy_…"
client_secret_env = "EVERASYNC_GAUZY_CLIENT_SECRET"
export EVERASYNC_GAUZY_CLIENT_SECRET="…"
gauzy is the only section that accepts a base_url, because Ever Gauzy is
the only one of the five that a customer can deploy themselves — api.gauzy.co
is merely Ever's instance. Setting it moves every Gauzy endpoint at once
(/authorize, /token, /api/auth/authenticated, /api/user/me). A base_url
on slack, discord, github or google is a startup error, not a key
that quietly does nothing.
5. Traps
There is no config key for a signing secret, and asking for one is fatal.
Gauzy signs every access token in every tenant with one global HS256 secret, so
anything holding it can mint a token for any user anywhere. A gauzy section
naming jwt_secret, jwt_secret_env, signing_key or token_secret fails at
startup with an explanation rather than being ignored — because an operator who
wrote it believes Ever Async verifies tokens locally, and it does not.
Gauzy lets unconfirmed accounts sign in; Ever Async does not. A Gauzy profile
is accepted only when emailVerifiedAt is set or isEmailVerified is true.
Neither set counts as unverified. This check is Ever Async's own, not a
restatement of one Gauzy already made — without it, someone could register at
app.gauzy.co claiming an address they do not control and walk straight through
allowed_domains.
No PKCE. Gauzy's registry refuses to register a public client at all
("Public (PKCE) client support is pending") and its /token handler reads no
code_verifier, so a challenge today would at best be ignored. pkce = true in
the section forces it on the day Gauzy ships it.
Registering the client is not the last step. Until a
WorkspaceBinding { channel: "gauzy", workspace: <Gauzy tenantId>, tenant: <Ever Async tenant> }
exists, the flow works but has no way to know two Gauzy colleagues belong
together: the first person through provisions a tenant and everyone after joins
by membership. Bind the tenant to get the no-picker path.
6. If it fails
| What you see | What it was | Code at /#error= |
|---|---|---|
A Gauzy 400 — {"statusCode":400,"message":"Invalid redirect_uri"} | The URI is not in redirectUris | (none) |
"Sign-in did not complete — unauthorized" | Gauzy answered false to the token check, the exchange was refused, the account's email is unconfirmed, or the domain is outside allowed_domains | unauthorized |
"Sign-in did not complete — access_denied" | Deny on Gauzy's consent screen | access_denied |
| The server refuses to start, naming a key | The section contains a signing-key setting — remove it | (server did not boot) |
Turning it on
The config
[auth] mode and provider switch enforcement on; [auth.providers.oauth]
carries the shared settings; each sub-section switches one identity provider on.
[auth]
mode = "authenticated"
provider = "oauth"
[auth.providers.oauth]
base_url = "https://app-async.ever.co" # required — callbacks are built from it
allowed_domains = [] # [] = anyone; read the warning below
session_ttl_secs = 604800 # optional, 7 days, clamped 300..=2592000
# enabled = ["slack", "google"] # optional; default is "every section below"
[auth.providers.oauth.slack]
client_id = "1234567890.1234567890"
client_secret_env = "EVERASYNC_SLACK_CLIENT_SECRET"
[auth.providers.oauth.discord]
client_id = "0123456789012345678"
client_secret_env = "EVERASYNC_DISCORD_CLIENT_SECRET"
[auth.providers.oauth.github]
client_id = "Ov23li0123456789abcd"
client_secret_env = "EVERASYNC_GITHUB_CLIENT_SECRET"
[auth.providers.oauth.google]
client_id = "1234567890-abcdef.apps.googleusercontent.com"
client_secret_env = "EVERASYNC_GOOGLE_CLIENT_SECRET"
[auth.providers.oauth.gauzy]
base_url = "https://api.gauzy.co" # optional — the only section that takes one
client_id = "gauzy_…"
client_secret_env = "EVERASYNC_GAUZY_CLIENT_SECRET"
Four things the config reader will refuse at startup rather than paper over, all of them worth knowing before you restart into them:
- a missing
base_url(there would be no callback URL to build); - a sub-section whose name is not one of the five —
[auth.providers.oauth.gogle]is an error, not one missing button; - an
enabledentry with no matching section; - an empty
client_id, or aclient_secret_envnaming a variable that is set but blank.
The environment
export EVERASYNC_SLACK_CLIENT_SECRET="…"
export EVERASYNC_DISCORD_CLIENT_SECRET="…"
export EVERASYNC_GITHUB_CLIENT_SECRET="…"
export EVERASYNC_GOOGLE_CLIENT_SECRET="…"
export EVERASYNC_GAUZY_CLIENT_SECRET="…"
On Kubernetes these are five keys in the same Secret the deployment already
consumes with envFrom — see
Self-hosting → Kubernetes. The TOML goes
in the ConfigMap; the values never do.
Ever Async reads everasync.toml once, at startup. And the standard mount for
it is a subPath ConfigMap mount:
volumeMounts:
- name: config
mountPath: /app/everasync.toml
subPath: everasync.toml
readOnly: true
A subPath mount is resolved once when the container starts and is never
updated by the kubelet afterwards — unlike a whole-directory ConfigMap mount,
which the kubelet does refresh. So editing the ConfigMap changes nothing at all
in the running pod: not the file on disk, and certainly not the process's idea of
the config. Environment variables from a Secret behave the same way.
kubectl -n <namespace> rollout restart deployment/everasync
kubectl -n <namespace> rollout status deployment/everasync
The symptom when this is forgotten is genuinely confusing: /api/v1/auth/mode
keeps reporting local_trusted with sso_providers: [] long after the ConfigMap
shows the new block, and it looks like the config was rejected.
Restarting also logs everyone out — sessions are an in-memory map per process. That is fine here, since you are turning sign-in on for the first time.
Verify with /api/v1/auth/mode
This route is public precisely so an unauthenticated dashboard can ask what to render — which makes it the fastest check that the config landed:
curl -s https://app-async.ever.co/api/v1/auth/mode | jq
Expected, with all five sections configured:
{
"mode": "authenticated",
"supports_login": false,
"provider": "oauth",
"sso_providers": [
{ "id": "slack", "name": "Slack" },
{ "id": "discord", "name": "Discord" },
{ "id": "github", "name": "GitHub" },
{ "id": "google", "name": "Google" },
{ "id": "gauzy", "name": "Ever Gauzy" }
]
}
How to read it:
| Field | Expected | If it is wrong |
|---|---|---|
mode | "authenticated" | "local_trusted" means the restart did not happen, or [auth] mode is not set. The dashboard will not show any sign-in screen |
provider | "oauth" | Another id means a different provider is registered — only one is active at a time |
supports_login | false | Always false for oauth: signing in is a redirect, not a form this server can render |
sso_providers | One entry per configured section, in this order | A missing entry is a section that was not read — a typo'd name, enabled = false inside it, or an enabled list that excludes it. It is always an array, never absent |
Then click the button. A completed sign-in returns to the dashboard root with the
session in the URL fragment (/#token=…), which the dashboard consumes and
scrubs from the address bar in the same breath — a fragment is never sent to a
server, so the token stays out of access logs and Referer headers.
Confirm the gate is real from both directions, because a config typo that leaves
you in local_trusted looks exactly like success if you only test the happy path:
curl -s -o /dev/null -w 'anon %{http_code}\n' https://app-async.ever.co/api/v1/status
# expect 401
Reading a failure
Every refusal returns the browser to the dashboard root with one bare code in
the fragment, and the dashboard renders it as
"Sign-in did not complete — <code>". There are exactly four codes:
/#error= | Emitted when |
|---|---|
access_denied | The identity provider itself returned an error in the callback — in practice, somebody clicked Cancel or Deny on the consent screen. Nothing failed |
invalid_request | The callback arrived without both code and state. A hand-edited or truncated URL |
unauthorized | The exchange was refused. Everything real lands here — see below |
server_error | The tenant could not be resolved (storage down, or 32 slugs already taken on one email domain), or too many logins were already in flight when start was called |
unauthorized is deliberately one code for many causesUnknown, replayed or expired state; a redirect_uri that does not match the
one the login started with; a refused authorization code; an unreachable identity
provider; a profile with no subject; an unverified email; a domain the
allowed_domains list refuses — all of them produce the identical
#error=unauthorized.
That is the design, not a gap: a response that distinguished "your email is not
verified" from "your domain is not allowed" from "that state was replayed" is a
free probe. The discriminator goes to tracing at debug and nowhere else.
So the second half of every diagnosis is the server log:
RUST_LOG=info,ever_async=debug everasync serve
# on Kubernetes: set RUST_LOG in the Deployment and read `kubectl logs`
Look for oauth: rejected events. The reason field is written to be read:
reason in the log | What to do |
|---|---|
profile email is not marked verified by the provider | Slack / Discord / Google: the user verifies their address with the provider |
no primary verified email on the account (an unverified address is not an identity) | GitHub: the user verifies their primary address |
gauzy has not confirmed this account's email address | Ever Gauzy: the user follows the confirmation link Gauzy mailed them |
email domain is not in `allowed_domains` | Your list, your call — add the domain or tell the person they are not invited |
callback state is unknown, already used, or expired | A back-button replay, a bookmarked callback, or more than ten minutes on the consent screen. Start again |
callback redirect_uri does not match the one the login started with | base_url changed between start and callback — usually an ephemeral tunnel that restarted |
token endpoint reported ok=false (code: …) / token endpoint returned an error (code: …) | The provider's own error code is in the message — bad_verification_code, invalid_grant, redirect_uri_mismatch |
profile request returned 401 Unauthorized | The token was accepted but the profile call was not — usually a missing scope |
the provider reported that this access token is not authenticated | Ever Gauzy answered false to /api/auth/authenticated |
And remember the case that produces no code at all: a redirect-URI mismatch registered at the provider means the browser never comes back, so there is nothing in the fragment and nothing in the Ever Async log. If a user reports "nothing happened, I'm stuck on Google", that is this — go back to the table at the top.
allowed_domains is a decision, not a default
allowed_domains = [] — or the key omitted entirely — means every account at
every provider you enabled can complete a login. Everyone with a Google account.
Everyone with a GitHub account. That is not a misconfiguration; it is what a
public identity provider is.
Which answer is correct depends entirely on whether tenancy is doing the work:
- Public SaaS — an empty list is the intended setting. A stranger who signs up is provisioned their own, new, empty tenant and is the board of it. They can reach nothing but rows they create, because isolation is enforced in the storage layer. Open registration is the product.
- Private or single-tenant self-host — an empty list is almost certainly
wrong. If successful logins land in the one tenant that already holds your
charter, your channel policies and your history, an open allowlist hands a
stranger the charter. Set
allowed_domains = ["your-company.example"].
Ever Async does not guess which shape you are: it applies the list if there is
one, allows every domain if there is not, and logs a warn at startup when the
list is empty — "oauth sign-in is enabled with no allowed_domains…" — so the
choice is at least visible in the boot log. Decide it on purpose.
Matching is on the whole domain after the @, case-insensitively, with a
leading @ and surrounding whitespace trimmed. Subdomains are not implied:
allowed_domains | alice@ever.co | bob@mail.ever.co | eve@notever.co |
|---|---|---|---|
["ever.co"] | ✅ | ❌ | ❌ |
["ever.co", "mail.ever.co"] | ✅ | ✅ | ❌ |
[] | ✅ | ✅ | ✅ |
An address that is not an address at all — no @, an empty local part, an empty
domain — is refused rather than guessed at.
Reference: what the code actually sends
Everything on this page in one table, for checking a console entry against
without scrolling. Read out of ProviderKind in
crates/plugins/auth-oauth/src/providers.rs.
| Slack | Discord | GitHub | Ever Gauzy | ||
|---|---|---|---|---|---|
| Config id | slack | discord | github | google | gauzy |
| Button label | Slack | Discord | GitHub | Ever Gauzy | |
| Scope sent | openid,email,profile | identify email | read:user user:email | openid email profile | profile email |
| Separator | comma | space | space | space | space |
PKCE (S256) | no | yes | no | yes | no |
base_url accepted | no | no | no | no | yes |
| Reports a workspace | yes (team id) | no | no | no | yes (tenantId) |
| Authorize | Token | Profile | |
|---|---|---|---|
| Slack | https://slack.com/openid/connect/authorize | https://slack.com/api/openid.connect.token | https://slack.com/api/openid.connect.userInfo |
| Discord | https://discord.com/oauth2/authorize | https://discord.com/api/oauth2/token | https://discord.com/api/users/@me |
| GitHub | https://github.com/login/oauth/authorize | https://github.com/login/oauth/access_token | https://api.github.com/user + https://api.github.com/user/emails |
https://accounts.google.com/o/oauth2/v2/auth | https://oauth2.googleapis.com/token | https://openidconnect.googleapis.com/v1/userinfo | |
| Ever Gauzy | {base_url}/api/integration/ever-gauzy/oauth/authorize | {base_url}/api/integration/ever-gauzy/oauth/token | {base_url}/api/auth/authenticated then {base_url}/api/user/me |
Timings and limits that shape what a user experiences:
| Value | |
|---|---|
state lifetime | 10 minutes, single-use, 32 CSPRNG bytes |
| Session lifetime | 7 days by default; session_ttl_secs, clamped 300..=2592000 |
| HTTP timeout per upstream call | 10 s (5 s to connect) |
| Logins in flight at once | 10 000, then start answers server_error |
Where to go next
- Security & authentication — the threat model, the role-to-endpoint table, what stays public, and the lockdown checklist.
- Configuration — the full key
reference for
[auth.providers.oauth]. - Ever Gauzy SSO — the registration call, why the Plane handoff is not copied, and how Gauzy's model maps onto ours.
- Channels → Slack — the other half of the Slack app: the bot that reads messages and delivers nudges.