Skip to main content

Ever Gauzy SSO

Goal: someone signed in to app.gauzy.co clicks through to Ever Async and lands in their own tenant, with no second password.

What Gauzy actually offers (verified in source, 2026-08-07)​

Two different things exist, and only one of them is a real integration surface:

What it isUse it?
OAuth 2.0 authorization-code server at /api/integration/ever-gauzy/oauth/*, backed by an oauth_clients registryA genuine, revocable, per-client OAuth server: authorize → consent → code → token✅ yes
The Plane SSO handoff (?sso=<token> → POST /sso-exchange)The user's live Gauzy access token passed in a URL query string, verified by a shared JWT_SECRET❌ no

Gauzy is not an OpenID Connect provider. There is no /.well-known/openid-configuration, no id_token, no JWKS, and no token introspection on the main API. Assume none of those exist when designing against it.

Why we do not copy the Plane pattern​

The Plane integration works, but its shape does not survive contact with a third-party SaaS:

  • It requires sharing JWT_SECRET. Gauzy signs access tokens with a single global HS256 secret. Verifying one locally means holding that secret — and anything holding it can mint Gauzy tokens for any user in any tenant. That is fine for a component Gauzy co-deploys; it is not something Ever Async should ever possess.
  • It puts a full-lifetime bearer token in a URL. Query strings land in browser history, Referer headers, and every intermediary log. The token is not short-lived, not single-use, and carries no audience restriction.

Both are acceptable trade-offs inside Gauzy's own deployment boundary. Neither is acceptable across one.

And why not the Ever Teams pattern either​

Ever Teams is the closest analogue — a genuinely separate app authenticating against Gauzy — and it proves the most important point: it needs no Gauzy secret at all. Its entire Gauzy-facing contract is two URLs (GAUZY_API_SERVER_URL, NEXT_PUBLIC_GAUZY_API_SERVER_URL). No JWT_SECRET, no admin key, no registered client. That confirms a third party can integrate cleanly, and it is strictly better than the Plane handoff.

But it is not SSO. Ever Teams is a replacement login UI: it collects the password or magic code itself, on its own origin, and posts the credential to Gauzy. A user already signed in at app.gauzy.co still logs in again from scratch. Copying it would mean asking people to type their Gauzy password into Ever Async — the credential-sharing antipattern OAuth exists to remove — and it would leave Ever Async holding Gauzy's full-scope access and refresh tokens, which Ever Teams keeps in cookies that are not httpOnly, not Secure and have no SameSite. One XSS there yields a week-long Gauzy refresh token.

Gauzy's OAuth server gives the same secret-free property plus the things that are missing: the user authenticates at app.gauzy.co (credentials never touch us), an explicit consent step, a registered client identity, a single-use code redeemed server-to-server, and per-app revocation.

Two things worth taking from Ever Teams​

  1. Its two-stage tenant selection is genuinely good, and we copy it. Gauzy returns all of a user's workspaces at once, each with its own scoped token; the client redeems the chosen one at POST /auth/signin.workspace. Mid-session switching is POST /auth/switch-workspace. That maps directly onto our membership list and POST /api/v1/auth/switch-tenant.
  2. Never verify a Gauzy token locally — decode at most, delegate always. Ever Teams gets this right: it only jwtDecodes (no signature check) to schedule refreshes, and lets Gauzy decide every authorization question. Copy the discipline. Do not copy the comment in its proxy.ts claiming "signature OK" from a decode result — that claim is simply false.

What Ever Async does instead​

Standard authorization-code flow against Gauzy's OAuth server, with the resulting token treated as opaque. This is implemented as a fifth ProviderKind in crates/plugins/auth-oauth — the same crate, the same tables and the same callback route as Slack, Discord, GitHub and Google:

  1. Ever Async is registered once in Gauzy's oauth_clients — see Register Ever Async in Gauzy below.
  2. The user clicks Sign in with Ever Gauzy → GET /api/v1/auth/oauth/gauzy/start redirects to {base_url}/api/integration/ever-gauzy/oauth/authorize?client_id=…&redirect_uri=…&response_type=code&scope=profile%20email&state=…. The state is 32 CSPRNG bytes, single-use, valid 10 minutes.
  3. Gauzy shows its consent screen and redirects back to /api/v1/auth/oauth/gauzy/callback?code=…&state=….
  4. Ever Async redeems the code at POST {base_url}/api/integration/ever-gauzy/oauth/token with grant_type=authorization_code, code, client_id, client_secret, redirect_uri, and reads access_token out of { access_token, token_type, expires_in, scope }.
  5. Identity is resolved by calling Gauzy with that token as a bearer, never by verifying it locally. Two calls, documented-first (see below).
  6. The tenantId Gauzy reports becomes OAuthIdentity.workspace, and crates/server/src/oauth.rs looks it up as a WorkspaceBinding on ("gauzy", <tenantId>). That is the no-picker path: everyone from a bound Gauzy tenant lands in the same Ever Async tenant. With no binding, the login falls through to the ordinary rules — an existing membership, then a fresh tenant of their own.

The token is never persisted beyond the exchange, and never appears in a URL we generate: the finished session is handed to the dashboard in the URL fragment.

Resolving the identity: which endpoint actually answers​

Both endpoints in the brief were tried against Gauzy's source, and only one of them carries an identity:

CallWhat it really returnsUsed for
GET {base}/api/auth/authenticatedA bare true / false with HTTP 200. The route is @Public(), so it never 401s — a bad token gets 200 false.Called first. false is Gauzy affirmatively refusing the token, and the login is rejected there.
GET {base}/api/user/meThe IUser row: id, email, tenantId, tenant, firstName/lastName/name, emailVerifiedAt, isEmailVerified.The fallback, and where every field of the identity comes from.

authenticated is still called first because it is the cheapest and most explicit "this token is dead" signal there is, and because a future Gauzy that answers it with a user object would then be used with no code change. The fallback is not a workaround — it is profile_fallback_endpoint, a first-class entry in the provider table.

The rule this provider exists to keep​

Gauzy's OAuth access token is a normal Gauzy JWT — exchangeOAuthAppAuthorizationCode mints it with getJwtAccessToken({ id, tenantId }), signed HS256 with the one global JWT_SECRET. So:

  • Ever Async never verifies a Gauzy token. No signature check, anywhere.
  • Ever Async never decodes one — not even without verification, "just for a hint". The identity comes from what Gauzy answered, so there is no decode to misread later. (Ever Teams' proxy.ts decodes and then comments that the signature is OK. It is not. Having no decode at all is the cheapest way not to inherit that.)
  • There is no config key that accepts a signing secret, and a section that names one (jwt_secret, jwt_secret_env, signing_key, token_secret…) is a startup error with an explanation — not a key that quietly does nothing. Silently ignoring it would leave an operator who had already pasted the most dangerous secret Gauzy has believing it was needed.

Two things fall out for free: a revoked Gauzy token stops working here immediately rather than staying valid until it expires, and a Gauzy secret incident cannot become an Ever Async one.

crates/plugins/auth-oauth has a test that scans its own source for the vocabulary of token verification (jsonwebtoken, hs256, decoding_key, hmac, jwks, …) and fails if any of it appears in code, plus a test that the config reader refuses a signing-key section.

Email verification is Ever Async's own check​

Gauzy mails a confirmation link on registration but does not block an unconfirmed account from signing in. Ever Async does: a Gauzy profile is accepted only when emailVerifiedAt is set or isEmailVerified is true, and absent counts as unverified — the same rule every other provider here follows.

Without it, someone could register at app.gauzy.co claiming an address they do not control, walk through a deployment's allowed_domains, and match an Ever Async invitation keyed by that address. The remedy for a real user is one click in a mail Gauzy already sent them.

PKCE​

Not sent, deliberately. Gauzy's client 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. supports_pkce() is false for Gauzy with that reason recorded next to it, and a deployment can still force it on with pkce = true the day Gauzy ships it.

Register Ever Async in Gauzy​

One-time, done by a Gauzy SUPER_ADMIN or ADMIN of the tenant — the endpoint is guarded by TenantPermissionGuard + PermissionGuard and needs the OAUTH_CLIENT_EDIT permission (granted through the ADMINISTRATION permission group).

curl -sS -X POST https://api.gauzy.co/api/oauth/clients \
-H "Authorization: Bearer $GAUZY_ADMIN_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "Ever Async",
"description": "Communication charter agent — sign-in only, no standing access.",
"clientType": "confidential",
"redirectUris": ["https://app-async.ever.co/api/v1/auth/oauth/gauzy/callback"],
"allowedScopes": ["profile", "email"],
"allowedGrantTypes": ["authorization_code"],
"pkceRequired": false
}'

Body fields, from CreateOAuthClientDTO:

FieldRequiredNotes
nameyes≤ 100 chars. Shown on Gauzy's consent screen.
descriptionno≤ 500 chars. Also shown on the consent screen.
clientTypenoconfidential (the default) — public is rejected today, because public clients need PKCE and that is deferred.
redirectUrisyesNon-empty array of absolute http/https URLs.
allowedScopesno["profile", "email"] — matches what Ever Async requests.
allowedGrantTypesno["authorization_code"] (the default); the only one implemented end to end.
pkceRequirednofalse. Leave it.
accessTokenTtlnoSeconds, 60…604800. Ever Async uses the token once and drops it, so the default is fine.
refreshTokenTtlnoNot used — Ever Async asks for no refresh token and keeps none.

🛑 The redirect URI must be exactly

https://app-async.ever.co/api/v1/auth/oauth/gauzy/callback

It is {[auth.providers.oauth] base_url}/api/v1/auth/oauth/gauzy/callback, built by OAuthProvider::callback_url, and it is re-checked at exchange time — a code redeemed against any other URI is refused. Gauzy checks it too, at both /authorize and /token. Register it character for character, including the scheme and no trailing slash.

The response returns the plaintext clientSecret exactly once; there is no way to read it back. Put it straight into the environment variable named by client_secret_env and nowhere else:

[auth.providers.oauth]
base_url = "https://app-async.ever.co"

[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"

base_url is the only provider section that accepts one, because Gauzy is the only one of the five that can be deployed more than once — api.gauzy.co is merely Ever's instance. Setting it moves every Gauzy endpoint at once. Setting one on slack, discord, github or google is a startup error rather than a key that does nothing.

Finally, bind the Gauzy tenant so its people land in the right Ever Async tenant instead of each getting their own: a WorkspaceBinding { channel: "gauzy", workspace: <Gauzy tenantId>, tenant: <Ever Async tenant> }. Until that binding exists the flow still works — the first person through provisions a tenant and everyone after joins by membership — it just has no way to know they are colleagues.

Mapping Gauzy's model onto ours​

Gauzy's shape is close enough to map cleanly:

GauzyEver Async
Tenant (root isolation boundary)Tenant
User — belongs to exactly one tenant; the same email in two tenants is two rowsPrincipal + one Membership per tenant
switch-workspace → re-issues the whole token pairPOST /api/v1/auth/switch-tenant → re-issues the session
Organization (sub-scope inside a tenant)not modelled yet — Ever Async scopes to tenant + chat workspace

The switching semantics are worth calling out because we arrived at them independently: the token is the tenant. Neither system keeps a server-side "current tenant" pointer that a request parameter can move.

One thing we deliberately do differently​

Gauzy's TenantAwareCrudService injects tenantId into queries only when a user is present in the request context; with no user it degrades to an unfiltered query, and several call sites bypass it on purpose. That is a footgun that relies on every author remembering.

Ever Async makes the tenant a required parameter of every scoped storage method, so "no tenant" is not representable and cannot silently mean "all tenants". Same intent, enforced by the type system instead of by discipline.