Set up enterprise JWT sign-in
Use this recipe when an organization wants its members to sign in to a team-mode Happy Agent with the organization's own identity provider (Okta, Entra ID, Google Workspace, Keycloak, an internal SSO, and so on) instead of Happy's WorkOS accounts. The outcome is a team-mode daemon that verifies JWT access tokens issued by the organization, and an OAuth authorization server, owned by the organization, that the Happy desktop or mobile app signs in with directly.
Read Team deployment mode first; everything there about installation, Tailcat, onboarding, and verification still applies. This recipe replaces only its WorkOS authentication. It does not authorize changing the organization's identity provider, registering applications in it, or publishing a service to the internet; ask the user's administrator for that access.
This requires a Happy Agent release that includes enterprise JWT authentication. Clients detect it
through GET /v0/authentication; an older daemon answers 404.
How sign-in works
It is the same pattern Slack and other desktop apps use for enterprise SSO: the app sends the person to the organization's sign-in page in the system browser, gets back a short-lived one-time code, and exchanges that code for tokens itself.
The daemon is treated as untrusted with credentials. It tells the app where to sign in and verifies the access token the app sends it, but it never sees the sign-in code, the PKCE secret, or a refresh token.
Happy app Happy Agent daemon Authorization server (deployer)
| GET /v0/authentication | |
|------------------------->| |
| oauth: authorizationUrl, tokenUrl, refreshUrl, clientId |
|<-------------------------| |
| system browser: authorizationUrl?client_id&redirect_uri&state&code_challenge
|------------------------------------------------------------>| SSO login with the
| | | identity provider
| browser redirects to redirect_uri?code=...&state=... |
|<------------------------------------------------------------|
| POST tokenUrl code + code_verifier |
|------------------------------------------------------------>|
| access_token (JWT), expires_in, refresh_token? |
|<------------------------------------------------------------|
| Authorization: Bearer <access_token> |
|------------------------->| verify signature, iss, aud, exp; read user ID
| ... later, when the access token expires ... |
| POST refreshUrl refresh_token |
|------------------------------------------------------------>|
| new access_token (and maybe a new refresh_token) |
|<------------------------------------------------------------|
- The Happy app asks the daemon how to sign in. Any
401from the daemon carries the same answer, so the app can start sign-in from any rejected request. - The daemon returns an
oauthmethod: the authorization, token, and optional refresh endpoints, the client ID, and an optional scope, straight from its configuration. - The app creates a random
stateand a PKCE verifier, keeps both in memory, and opens the authorization endpoint in the system browser, never an embedded web view. It shows the authorization server's host so the person can see where they are signing in. - The authorization server signs the person in with the identity provider and redirects to the
app's
redirect_uriwith a one-time code and thestate. On desktop the redirect URI is a loopback address such ashttp://127.0.0.1:53682/callback; on mobile it is the app's own URL scheme. - The app checks
stateand exchanges the code, together with the PKCE verifier, at the token endpoint. Anyone who intercepts the redirect gets only a code that is useless without the verifier and expires within seconds. - The app sends the access token to the daemon on every request. It may keep all tokens only in memory.
- Before or when the access token expires, the app sends the refresh token to the refresh endpoint, if there is one, and receives a new access token. Without a refresh endpoint, or when refresh fails, the app goes through the browser again. That is usually instant while the identity provider's own session is still valid, which is how an app can keep credentials only in memory and still rarely prompt.
The app sends a refresh token only to the refresh endpoint recorded when it signed in, never to one
a daemon advertises later. Client code uses beginOAuthSignIn, completeOAuthSignIn, and
refreshOAuthCredential from @slopus/happy-agent-client; HappyAgentClient accepts a token
function so a refreshed token applies without recreating the client.
1. Choose the authorization server
The deployer needs an OAuth 2.0 authorization server that supports the authorization code flow with PKCE for a public client and issues JWT access tokens. There are two ways:
- Use the identity provider directly. Okta, Entra ID, Auth0, Keycloak, Ping, and most modern identity providers do this out of the box. Register a native or public application, allow the Happy app's redirect URIs, define an API or resource for this deployment so access tokens carry its audience, and enable refresh tokens if wanted. No custom code is needed.
- Write a small authorization server in front of an identity provider that cannot issue suitable access tokens, for example a SAML-only one. See step 3.
2. Agree the token contract
- Issuer (
iss): the authorization server's issuer, for examplehttps://sso.acme.example. - Audience (
aud): a value unique to this deployment, for examplehttps://happy.acme.example. The daemon sees every token sent to it; a unique audience stops it from replaying a member's token to another service or another Happy deployment. - User ID claim: a stable, never-reused user ID.
subis the default. Prefer an immutable object ID over an email address. Values are 1–256 printable characters. - Owner: the user ID of the person who should become the deployment owner.
- Client ID and optional scope: whatever the authorization server registered for the
Happy app. Request
offline_accessor the provider's equivalent if refresh tokens need it. - Lifetimes: short access tokens, typically 5–60 minutes, with refresh tokens if the organization wants long sessions. Rotate refresh tokens on use where the server supports it.
- Signing key: prefer an asymmetric key published as a JWKS URL, which every identity provider
offers. Algorithms:
RS256,RS384,RS512,PS256,PS384,PS512,ES256,ES384,ES512,EdDSA. A shared secret (HS256,HS384,HS512, at least 32 bytes) is supported, but whoever holds it can mint tokens, including the daemon itself. Never use a shared secret when the daemon is not fully trusted, and never share one between deployments.
Every access token must carry iss, aud, exp, and the user ID claim. nbf and iat are
checked when present. Clocks may differ by up to 30 seconds.
3. If you write the authorization server yourself
Implement three endpoints following RFC 6749, RFC 7636, and RFC 8252. Any OAuth server library can do this; the essentials are:
GET /oauth/authorize receives response_type=code, client_id, redirect_uri, state,
code_challenge, code_challenge_method=S256, and optional scope.
- Check
client_idand checkredirect_uriexactly against an allow-list of the Happy app's callbacks, for examplehappy://auth/callbackandhttp://127.0.0.1:<any port>/callback. Never redirect anywhere else, not even to report an error. - Sign the person in with the identity provider, and check that they may use Happy.
- Create a random, single-use code that expires within about a minute, and store it with the user,
client_id,redirect_uri, andcode_challenge. - Redirect to
redirect_uri?code=<code>&state=<state>, copyingstateunchanged.
POST /oauth/token receives a form with grant_type=authorization_code, code,
redirect_uri, client_id, and code_verifier. Consume the code, check that client_id and
redirect_uri match, and check that the base64url SHA-256 of code_verifier equals the stored
challenge. Respond with JSON:
{
"access_token": "<JWT>",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "<opaque random string>"
}
Errors use 400 with { "error": "invalid_grant" } or another OAuth error code.
POST /oauth/refresh (optional; it can also be the token endpoint) receives
grant_type=refresh_token, refresh_token, and client_id, and responds the same way. Store
refresh tokens hashed, rotate them on every use, revoke them when a person leaves, and treat reuse
of a rotated token as theft by revoking the whole session. The refresh endpoint may live on a
different origin from the other two.
Minting the access token with jose:
import { importPKCS8, SignJWT } from "jose";
const privateKey = await importPKCS8(process.env.HAPPY_SIGNING_KEY!, "ES256");
export async function accessToken(userId: string): Promise<string> {
return await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: "happy-2026-09" })
.setIssuer("https://sso.acme.example")
.setAudience("https://happy.acme.example")
.setSubject(userId)
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
Publish the public key as a JWKS at a stable https URL, include kid in both the key and the
token header, and rotate by publishing a new key before signing with it. Keep the old key in the
JWKS until every token it signed has expired, then remove it; the daemon stops accepting it at its
next download. Serve every endpoint over
https; plain http is accepted only on a loopback host for local testing.
4. Configure the daemon
Follow Team deployment mode to install the binary and bootstrap Tailcat, but
instead of the WorkOS settings, write the machine happy.toml like this:
[feature.team]
enabled = true
host = "0.0.0.0"
port = 3000
authentication = "jwt"
owner_user_id = "REPLACE_WITH_OWNER_USER_ID"
[feature.team.jwt]
name = "Acme SSO"
authorization_url = "https://sso.acme.example/oauth/authorize"
token_url = "https://sso.acme.example/oauth/token"
refresh_url = "https://sso.acme.example/oauth/refresh"
client_id = "happy"
scope = "happy offline_access"
issuer = "https://sso.acme.example"
audience = "https://happy.acme.example"
user_id_claim = "sub"
algorithms = ["ES256"]
jwks_url = "https://sso.acme.example/.well-known/jwks.json"
jwks_refresh_interval_sec = 3600
name is what the Happy app shows on the sign-in button. Leave out refresh_url to turn refresh
off; leave out scope if the server needs none. Configure exactly one key source:
jwks_url = "https://..."for a published key set (recommended). The daemon downloads the keys itself when it starts and again everyjwks_refresh_interval_secseconds (60–86,400, default 3,600), so no key is written into configuration. A token signed with a key the daemon has not seen yet triggers an immediate download, at most every 30 seconds, so rotations apply at once. If a download fails, the daemon keeps the last good keys and retries after a minute; a key removed from the JWKS stops working at the next successful download;public_key = """-----BEGIN PUBLIC KEY-----..."""for one PEM public key;secret_env = "HAPPY_TEAM_JWT_SECRET"for a shared secret, kept in that environment variable of the daemon's service and never inhappy.toml.
Do not set workos_organization_id, owner_workos_user_id, or workos_client_id with JWT
authentication; the daemon refuses the combination. Configuration mistakes stop startup with a
readable message, so read the service log if the daemon does not come up.
Users are identified by the method and user ID together. A deployment that switches from WorkOS to JWT starts with fresh member accounts; existing WorkOS users stay stored but cannot sign in through JWT.
5. Verify
Run these checks from a machine that can reach the daemon, replacing DAEMON with its address.
-
Discovery answers without a token:
curl -s "DAEMON/v0/authentication"Expect
"authenticated": falseand oneoauthmethod with the configured endpoints and client ID, and no secret, key, or JWKS content. -
Sign in from the Happy app, or test the flow by hand: open the authorization URL with a loopback
redirect_uri, astate, and a PKCE challenge, confirm the browser lands onredirect_uri?code=...&state=..., and exchange the code at the token endpoint. -
Call discovery with the access token and expect
"authenticated": true.userIdstaysnulluntil the person saves a profile. -
Confirm that a tampered, expired, or wrong-audience token gets
401with anauthenticationfield listing the same method, and that a token for another audience is rejected. -
If refresh is configured, refresh once and confirm that the new access token works and that the old refresh token is rejected when rotation is on.
-
Sign in as the owner in the Happy app, complete onboarding, and run the deployed-node test. Then sign in as a second member and confirm they are not the owner.
Troubleshooting
- The browser shows an error instead of redirecting. The redirect URI is not registered with the authorization server, or the client ID is wrong.
- The code exchange fails with
invalid_grant. The code expired or was already used, the redirect URI differs from the one used to authorize, or the server does not verify PKCE with S256. - Every access token is rejected right after startup. The daemon could not download the JWKS:
check that the daemon can reach
jwks_urloverhttpswithout a redirect, and read the service log for the key set warning. - Every access token is rejected. Compare
iss,aud,alg, andkidwith the configuration exactly, and check the server clock. Some identity providers issue opaque access tokens unless an API or resource audience is requested; the daemon needs a JWT. - Refresh never happens.
refresh_urlis not configured, the token response has norefresh_token, or the scope lacksoffline_accessor its equivalent. - Someone became the owner unexpectedly. Owner status comes only from
owner_user_idmatching the token's user ID claim.
Never paste real tokens, codes, private keys, or shared secrets into a conversation, issue, or log.