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 is | Use it? | |
|---|---|---|
OAuth 2.0 authorization-code server at /api/integration/ever-gauzy/oauth/*, backed by an oauth_clients registry | A 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,
Refererheaders, 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
- 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 isPOST /auth/switch-workspace. That maps directly onto our membership list andPOST /api/v1/auth/switch-tenant. - 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 itsproxy.tsclaiming "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:
- Ever Async is registered once in Gauzy's
oauth_clients— see Register Ever Async in Gauzy below. - The user clicks Sign in with Ever Gauzy →
GET /api/v1/auth/oauth/gauzy/startredirects to{base_url}/api/integration/ever-gauzy/oauth/authorize?client_id=…&redirect_uri=…&response_type=code&scope=profile%20email&state=…. Thestateis 32 CSPRNG bytes, single-use, valid 10 minutes. - Gauzy shows its consent screen and redirects back to
/api/v1/auth/oauth/gauzy/callback?code=…&state=…. - Ever Async redeems the code at
POST {base_url}/api/integration/ever-gauzy/oauth/tokenwithgrant_type=authorization_code,code,client_id,client_secret,redirect_uri, and readsaccess_tokenout of{ access_token, token_type, expires_in, scope }. - Identity is resolved by calling Gauzy with that token as a bearer, never by verifying it locally. Two calls, documented-first (see below).
- The
tenantIdGauzy reports becomesOAuthIdentity.workspace, andcrates/server/src/oauth.rslooks it up as aWorkspaceBindingon("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:
| Call | What it really returns | Used for |
|---|---|---|
GET {base}/api/auth/authenticated | A 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/me | The 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.tsdecodes 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:
| Field | Required | Notes |
|---|---|---|
name | yes | ≤ 100 chars. Shown on Gauzy's consent screen. |
description | no | ≤ 500 chars. Also shown on the consent screen. |
clientType | no | confidential (the default) — public is rejected today, because public clients need PKCE and that is deferred. |
redirectUris | yes | Non-empty array of absolute http/https URLs. |
allowedScopes | no | ["profile", "email"] — matches what Ever Async requests. |
allowedGrantTypes | no | ["authorization_code"] (the default); the only one implemented end to end. |
pkceRequired | no | false. Leave it. |
accessTokenTtl | no | Seconds, 60…604800. Ever Async uses the token once and drops it, so the default is fine. |
refreshTokenTtl | no | Not 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:
| Gauzy | Ever Async |
|---|---|
Tenant (root isolation boundary) | Tenant |
User — belongs to exactly one tenant; the same email in two tenants is two rows | Principal + one Membership per tenant |
switch-workspace → re-issues the whole token pair | POST /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.