Directus + Cloudron SSO: "Invalid token" login loop
-
TL;DR — After Cloudron's OIDC refresh token for a Directus user expires or is revoked, Directus can never log that user in again, even though Cloudron successfully authenticates them. The dead token sits in
directus_users.auth_data, Directus reuses it on every login, and treats Cloudron'sinvalid_grantas fatal. Clearing the column fixes it instantly.Environment
- Cloudron 10.1.5, official Directus app, Directus 12.5.0 on PostgreSQL
- SSO via Cloudron's OIDC provider:
AUTH_PROVIDERS=cloudron,AUTH_CLOUDRON_DRIVER=openid, issuerhttps://my.example.com/openid
Symptom — Clicking "Login with Cloudron" bounces back to the login page with
reason=INVALID_TOKEN. The log shows a loop:GET /auth/login/cloudron?redirect=... 302 GET /auth/login/cloudron/callback?code=...&state=...&iss=... 302 WARN: [OpenID] Invalid grant OPError: invalid_grant (grant request is invalid) at Client.grant (openid-client/lib/client.js:1381) at async Client.refresh (openid-client/lib/client.js:1125) at async OpenIDAuthDriver.refresh (directus/api/dist/auth/drivers/openid.js:250) at async AuthenticationService.login (.../services/authentication.js:140) WARN: Invalid token. GET /admin/login?reason=INVALID_TOKEN 200 GET /auth/login/cloudron/?...&prompt=true 302 <- automatic retry, fails againIt never recovers, no matter how often you retry.
What is not broken — the stack is the giveaway: the failing grant is
Client.refresh, called fromAuthenticationService.login. The authorization-code exchange (Client.callback) already succeeded — Cloudron authenticated the user, issued a valid code, and Directus identified the Directus user. So client ID, client secret, redirect URI, PKCE, scopes and the user mapping are all fine. That's why the usual advice (check redirect URI, rotate the secret, clear cookies) does nothing here.Root cause —
api/src/auth/drivers/openid.ts:override async login(user: User): Promise<void> { return this.refresh(user); // login re-uses the token stored last time } override async refresh(user: User): Promise<void> { if (authData?.['refreshToken']) { const tokenSet = await client.refresh(authData['refreshToken']); // invalid_grant -> InvalidTokenError } }and, when the user is looked up during the callback:
auth_data: tokenSet.refresh_token && JSON.stringify({ refreshToken: tokenSet.refresh_token }),Three things combine into a permanent lockout:
- Cloudron's refresh token is only valid for as long as the Cloudron/OIDC session. This matches what Directus maintainers state in directus/directus#22279: once the provider session expires, the user has to log in again. That is expected behaviour.
- When the token has expired, the next login still calls
client.refresh()with the old token before completing.invalid_grant→InvalidTokenError→ login aborted, and the fresh code exchange is thrown away. auth_datais only overwritten when the code exchange returns a new refresh token. If it doesn't, the key isundefined,updateOne()strips it, and the stale token stays in the database forever. So the automatic retry withprompt=true(the callback handler'sInvalidToken → ./?prompt=trueredirect) can never succeed either. It's a deadlock that only a direct database update breaks.
Immediate fix — clear the stored token for the affected user(s):
SELECT id, email, provider, auth_data FROM directus_users WHERE provider = 'cloudron'; UPDATE directus_users SET auth_data = NULL WHERE provider = 'cloudron';On Cloudron, the app's web terminal works:
PGPASSWORD=$CLOUDRON_POSTGRESQL_PASSWORD psql \ -h "$CLOUDRON_POSTGRESQL_HOST" -U "$CLOUDRON_POSTGRESQL_USERNAME" \ -d "$CLOUDRON_POSTGRESQL_DATABASE" -c \ "UPDATE directus_users SET auth_data = NULL WHERE provider = 'cloudron';"The next SSO login succeeds immediately. (The
POST /auth/refresh 400calls in the log are unrelated — that's the Studio retrying a dead Directus session cookie, and it clears itself once a new session exists.)Proposed clean fix (upstream, Directus) — two small changes, either of which removes the deadlock:
- Never persist a stale provider token — make the absence explicit:
auth_data: tokenSet.refresh_token ? JSON.stringify({ refreshToken: tokenSet.refresh_token }) : null,- Treat an unusable stored token as "no token" during login, and only fail on a genuine refresh attempt:
catch (e) { // during login a dead stored token must not block the (already authenticated) login if (e.error === 'invalid_grant' && <called from login>) { await clearAuthData(user.id); return; } throw handleError(e); }Arguably
login()should not callrefresh()at all: it has just received fresh tokens from the authorization code, and the refresh call only validates a session that is being re-established regardless.Quick triage for anyone else hitting this — if the log shows
[OpenID] Invalid grantwithClient.refreshin the stack (notClient.callback), it's this bug. Don't touch your client ID/secret; cleardirectus_users.auth_datafor that user. -
Hello @dbruehlmeier
As you have identified correctly, this is an upstream issue.
So please report this to Directus https://github.com/directus/directus/issues -
Thanks @james !
-
-
@jdbaker good tip, thanks! Let's hope the root cause will be adressed by Directus.
Hello! It looks like you're interested in this conversation, but you don't have an account yet.
Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.
With your input, this post could be even better 💗
Register Login