<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Directus + Cloudron SSO: "Invalid token" login loop]]></title><description><![CDATA[<p dir="auto"><strong>TL;DR</strong> — 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 <code>directus_users.auth_data</code>, Directus reuses it on every login, and treats Cloudron's <code>invalid_grant</code> as fatal. Clearing the column fixes it instantly.</p>
<p dir="auto"><strong>Environment</strong></p>
<ul>
<li>Cloudron 10.1.5, official Directus app, Directus 12.5.0 on PostgreSQL</li>
<li>SSO via Cloudron's OIDC provider: <code>AUTH_PROVIDERS=cloudron</code>, <code>AUTH_CLOUDRON_DRIVER=openid</code>, issuer <code>https://my.example.com/openid</code></li>
</ul>
<p dir="auto"><strong>Symptom</strong> — Clicking "Login with Cloudron" bounces back to the login page with <code>reason=INVALID_TOKEN</code>. The log shows a loop:</p>
<pre><code>GET /auth/login/cloudron?redirect=...                       302
GET /auth/login/cloudron/callback?code=...&amp;state=...&amp;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/?...&amp;prompt=true                    302   &lt;- automatic retry, fails again
</code></pre>
<p dir="auto">It never recovers, no matter how often you retry.</p>
<p dir="auto"><strong>What is <em>not</em> broken</strong> — the stack is the giveaway: the failing grant is <code>Client.refresh</code>, called from <code>AuthenticationService.login</code>. The authorization-code exchange (<code>Client.callback</code>) 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.</p>
<p dir="auto"><strong>Root cause</strong> — <code>api/src/auth/drivers/openid.ts</code>:</p>
<pre><code class="language-ts">override async login(user: User): Promise&lt;void&gt; {
    return this.refresh(user);              // login re-uses the token stored last time
}

override async refresh(user: User): Promise&lt;void&gt; {
    if (authData?.['refreshToken']) {
        const tokenSet = await client.refresh(authData['refreshToken']); // invalid_grant -&gt; InvalidTokenError
    }
}
</code></pre>
<p dir="auto">and, when the user is looked up during the callback:</p>
<pre><code class="language-ts">auth_data: tokenSet.refresh_token &amp;&amp; JSON.stringify({ refreshToken: tokenSet.refresh_token }),
</code></pre>
<p dir="auto">Three things combine into a permanent lockout:</p>
<ol>
<li>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.</li>
<li>When the token has expired, the <em>next</em> login still calls <code>client.refresh()</code> with the old token before completing. <code>invalid_grant</code> → <code>InvalidTokenError</code> → login aborted, and the fresh code exchange is thrown away.</li>
<li><code>auth_data</code> is only overwritten when the code exchange returns a <strong>new</strong> refresh token. If it doesn't, the key is <code>undefined</code>, <code>updateOne()</code> strips it, and the stale token stays in the database forever. So the automatic retry with <code>prompt=true</code> (the callback handler's <code>InvalidToken → ./?prompt=true</code> redirect) can never succeed either. It's a deadlock that only a direct database update breaks.</li>
</ol>
<p dir="auto"><strong>Immediate fix</strong> — clear the stored token for the affected user(s):</p>
<pre><code class="language-sql">SELECT id, email, provider, auth_data FROM directus_users WHERE provider = 'cloudron';
UPDATE directus_users SET auth_data = NULL WHERE provider = 'cloudron';
</code></pre>
<p dir="auto">On Cloudron, the app's web terminal works:</p>
<pre><code class="language-sh">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';"
</code></pre>
<p dir="auto">The next SSO login succeeds immediately. (The <code>POST /auth/refresh 400</code> calls in the log are unrelated — that's the Studio retrying a dead Directus session cookie, and it clears itself once a new session exists.)</p>
<p dir="auto"><strong>Proposed clean fix (upstream, Directus)</strong> — two small changes, either of which removes the deadlock:</p>
<ol>
<li>Never persist a stale provider token — make the absence explicit:</li>
</ol>
<pre><code class="language-ts">auth_data: tokenSet.refresh_token ? JSON.stringify({ refreshToken: tokenSet.refresh_token }) : null,
</code></pre>
<ol start="2">
<li>Treat an unusable <em>stored</em> token as "no token" during login, and only fail on a genuine refresh attempt:</li>
</ol>
<pre><code class="language-ts">catch (e) {
    // during login a dead stored token must not block the (already authenticated) login
    if (e.error === 'invalid_grant' &amp;&amp; &lt;called from login&gt;) { await clearAuthData(user.id); return; }
    throw handleError(e);
}
</code></pre>
<p dir="auto">Arguably <code>login()</code> should not call <code>refresh()</code> 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.</p>
<p dir="auto"><strong>Quick triage for anyone else hitting this</strong> — if the log shows <code>[OpenID] Invalid grant</code> with <code>Client.refresh</code> in the stack (not <code>Client.callback</code>), it's this bug. Don't touch your client ID/secret; clear <code>directus_users.auth_data</code> for that user.</p>
]]></description><link>https://forum.cloudron.io/topic/16165/directus-cloudron-sso-invalid-token-login-loop</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 02:12:46 GMT</lastBuildDate><atom:link href="https://forum.cloudron.io/topic/16165.rss" rel="self" type="application/rss+xml"/><pubDate>Wed, 21 Oct 2026 06:37:54 GMT</pubDate><ttl>60</ttl><item><title><![CDATA[Reply to Directus + Cloudron SSO: "Invalid token" login loop on Thu, 22 Oct 2026 05:37:28 GMT]]></title><description><![CDATA[<p dir="auto"><a class="plugin-mentions-user plugin-mentions-a" href="/user/jdbaker" aria-label="Profile: jdbaker">@<bdi>jdbaker</bdi></a> good tip, thanks! Let's hope the root cause will be adressed by Directus.</p>
]]></description><link>https://forum.cloudron.io/post/131484</link><guid isPermaLink="true">https://forum.cloudron.io/post/131484</guid><dc:creator><![CDATA[dbruehlmeier]]></dc:creator><pubDate>Thu, 22 Oct 2026 05:37:28 GMT</pubDate></item><item><title><![CDATA[Reply to Directus + Cloudron SSO: "Invalid token" login loop on Wed, 21 Oct 2026 16:53:55 GMT]]></title><description><![CDATA[<p dir="auto">I had this same issue! I think I found the same solution, except through the GUI. I had to log in as another administrator and change the affected user's login provider to default, save, and then change it back to Cloudron.</p>
]]></description><link>https://forum.cloudron.io/post/131409</link><guid isPermaLink="true">https://forum.cloudron.io/post/131409</guid><dc:creator><![CDATA[jdbaker]]></dc:creator><pubDate>Wed, 21 Oct 2026 16:53:55 GMT</pubDate></item><item><title><![CDATA[Reply to Directus + Cloudron SSO: "Invalid token" login loop on Wed, 21 Oct 2026 13:49:54 GMT]]></title><description><![CDATA[<p dir="auto">Thanks <a class="plugin-mentions-user plugin-mentions-a" href="/user/james" aria-label="Profile: james">@<bdi>james</bdi></a> !</p>
<p dir="auto">Done: <a href="https://github.com/directus/directus/issues/28389" target="_blank" rel="noopener noreferrer nofollow ugc">https://github.com/directus/directus/issues/28389</a></p>
]]></description><link>https://forum.cloudron.io/post/131351</link><guid isPermaLink="true">https://forum.cloudron.io/post/131351</guid><dc:creator><![CDATA[dbruehlmeier]]></dc:creator><pubDate>Wed, 21 Oct 2026 13:49:54 GMT</pubDate></item><item><title><![CDATA[Reply to Directus + Cloudron SSO: "Invalid token" login loop on Wed, 21 Oct 2026 09:10:12 GMT]]></title><description><![CDATA[<p dir="auto">Hello <a class="plugin-mentions-user plugin-mentions-a" href="/user/dbruehlmeier" aria-label="Profile: dbruehlmeier">@<bdi>dbruehlmeier</bdi></a><br />
As you have identified correctly, this is an upstream issue.<br />
So please report this to Directus <a href="https://github.com/directus/directus/issues" target="_blank" rel="noopener noreferrer nofollow ugc">https://github.com/directus/directus/issues</a></p>
]]></description><link>https://forum.cloudron.io/post/131328</link><guid isPermaLink="true">https://forum.cloudron.io/post/131328</guid><dc:creator><![CDATA[james]]></dc:creator><pubDate>Wed, 21 Oct 2026 09:10:12 GMT</pubDate></item></channel></rss>