Skip to main content

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.

The redirect URI is what breaks first attempts

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:

ProviderProduction — base_url = "https://app-async.ever.co"Local dev — base_url = "http://localhost:8100"
Slackhttps://app-async.ever.co/api/v1/auth/oauth/slack/callbackhttp://localhost:8100/api/v1/auth/oauth/slack/callback
Discordhttps://app-async.ever.co/api/v1/auth/oauth/discord/callbackhttp://localhost:8100/api/v1/auth/oauth/discord/callback
GitHubhttps://app-async.ever.co/api/v1/auth/oauth/github/callbackhttp://localhost:8100/api/v1/auth/oauth/github/callback
Googlehttps://app-async.ever.co/api/v1/auth/oauth/google/callbackhttp://localhost:8100/api/v1/auth/oauth/google/callback
Ever Gauzyhttps://app-async.ever.co/api/v1/auth/oauth/gauzy/callbackhttp://localhost:8100/api/v1/auth/oauth/gauzy/callback

Two consequences worth internalising before you start:

  • It is checked twice. The provider checks the redirect_uri you 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://localhost at all — Slack requires HTTPS redirect URLs, and a self-hosted Ever Gauzy is whatever you point it at. See each provider's section.
Ask the binary instead of trusting this table

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.

  1. Pick base_url. The public origin of the Ever Async API and dashboard host — https://app-async.ever.co in production. It is not the docs host and not the marketing site. Everything is derived from it.

  2. Decide allowed_domains. Read the warning below now, not after the first stranger signs up.

  3. 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 *_env rule. The names used throughout this page are the ones everasync init already 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 requires HTTPS — there is no localhost exception

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.

Commas are not a typo

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 isOpenID Connect identity — who is this person, what workspaceAn installed bot user with a xoxb- token
Configured byThe three user scopes aboveBot token scopes (chat:write, history scopes) + Event Subscriptions
TokenUsed once by auth-oauth to read the profile, then droppedLong-lived, held by [channels.slack]
Gives youA login buttonA 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 seeWhat it wasCode at /#error=
A Slack error page, never returning to Ever AsyncRedirect 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 refusedunauthorized
"Sign-in did not complete — access_denied"The person clicked Cancel on Slack's consent screenaccess_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 seeWhat it wasCode at /#error=
Discord's own "Invalid OAuth2 redirect_uri" pageThe 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_domainsunauthorized
"Sign-in did not complete — access_denied"Cancel on Discord's authorize screenaccess_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.

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
Keep environment credentials separate

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 seeWhat it wasCode 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_domainsunauthorized
"Sign-in did not complete — access_denied"Cancel on GitHub's authorize screenaccess_denied

Google​

1. Where to create the app​

Two steps in Google Cloud, in this order, in one project:

  1. 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.
  2. 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
"Authorized JavaScript origins" is not the field you want

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 seeWhat it wasCode 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_domainsunauthorized
"Sign-in did not complete — access_denied"Cancel on Google's consent screenaccess_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 seeWhat it wasCode 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_domainsunauthorized
"Sign-in did not complete — access_denied"Deny on Gauzy's consent screenaccess_denied
The server refuses to start, naming a keyThe 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 enabled entry with no matching section;
  • an empty client_id, or a client_secret_env naming 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.

A pod restart is required — the ConfigMap will not hot-reload

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:

FieldExpectedIf 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_loginfalseAlways false for oauth: signing in is a redirect, not a form this server can render
sso_providersOne entry per configured section, in this orderA 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_deniedThe identity provider itself returned an error in the callback — in practice, somebody clicked Cancel or Deny on the consent screen. Nothing failed
invalid_requestThe callback arrived without both code and state. A hand-edited or truncated URL
unauthorizedThe exchange was refused. Everything real lands here — see below
server_errorThe 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
warning
unauthorized is deliberately one code for many causes

Unknown, 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 logWhat to do
profile email is not marked verified by the providerSlack / 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 addressEver 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 expiredA 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 withbase_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 UnauthorizedThe token was accepted but the profile call was not — usually a missing scope
the provider reported that this access token is not authenticatedEver 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​

With no allowlist, anyone on earth can sign up

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_domainsalice@ever.cobob@mail.ever.coeve@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.

SlackDiscordGitHubGoogleEver Gauzy
Config idslackdiscordgithubgooglegauzy
Button labelSlackDiscordGitHubGoogleEver Gauzy
Scope sentopenid,email,profileidentify emailread:user user:emailopenid email profileprofile email
Separatorcommaspacespacespacespace
PKCE (S256)noyesnoyesno
base_url acceptednonononoyes
Reports a workspaceyes (team id)nononoyes (tenantId)
AuthorizeTokenProfile
Slackhttps://slack.com/openid/connect/authorizehttps://slack.com/api/openid.connect.tokenhttps://slack.com/api/openid.connect.userInfo
Discordhttps://discord.com/oauth2/authorizehttps://discord.com/api/oauth2/tokenhttps://discord.com/api/users/@me
GitHubhttps://github.com/login/oauth/authorizehttps://github.com/login/oauth/access_tokenhttps://api.github.com/user + https://api.github.com/user/emails
Googlehttps://accounts.google.com/o/oauth2/v2/authhttps://oauth2.googleapis.com/tokenhttps://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 lifetime10 minutes, single-use, 32 CSPRNG bytes
Session lifetime7 days by default; session_ttl_secs, clamped 300..=2592000
HTTP timeout per upstream call10 s (5 s to connect)
Logins in flight at once10 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.