Skip to content

feat(auth): allow OpenID/OAuth2 logins without a refresh token (AUTH_<PROVIDER>_REQUIRE_REFRESH_TOKEN) - #430

Open
slyk wants to merge 2 commits into
LaWebcapsule:mainfrom
slyk:upstream/oauth-optional-refresh-token
Open

slyk wants to merge 2 commits into
LaWebcapsule:mainfrom
slyk:upstream/oauth-optional-refresh-token

Conversation

@slyk

@slyk slyk commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Change description

Lets OpenID and OAuth2 logins work when the identity provider returns no refresh token (for example Google when the
account has already granted consent). Today such a login, and every /auth/refresh after it, is rejected with
INVALID_CREDENTIALS / "No [OpenID] Session found". Discussed and agreed in #401.

  • New option AUTH_<PROVIDER>_REQUIRE_REFRESH_TOKEN (default true, current behavior). With false, a missing
    refresh token no longer rejects the login or the session renewal; the d9 session is then the only source of truth for
    that user. A refresh token that is stored is still exchanged at the provider, and a revoked grant still ends the
    session.
  • Stored refresh token kept on re-login. getUserID() used to write auth_data: userPayload.auth_data ?? null, so a
    login without a new refresh token overwrote a still-valid stored one with null. It now writes auth_data only when
    the provider returned a new token. An update with nothing to write is skipped, so the empty .update() crash from Empty update in auth drivers thows an error, preventing login #127
    stays fixed.
  • Default now retries with consent. With the default (true), the missing-token branch of refresh() throws
    InvalidTokenException instead of InvalidCredentialsException. The OpenID and OAuth2 callbacks already catch it and
    redirect once with prompt=consent, so Google shows the consent screen, returns a refresh token and the login
    succeeds without any extra configuration. The existing prompt flag prevents a loop: if the provider still sends no
    token after consent, the login fails as before, and that is where the new option applies.
  • On /auth/refresh the missing-token case now returns 403 INVALID_TOKEN (what a revoked token already returns)
    instead of 401 INVALID_CREDENTIALS. The app logs out on either.
  • Docs: the option in config-options.md (with the trade-off) and a consent-screen tip in the Google section of sso.md.
  • Changeset: @wbce-d9/api minor.

Testing note

As mentioned in #401: this was tested against Google only, in our own setup (one Google Cloud project, several d9
instances, each a separate OAuth client, all used by the same few accounts). It was not tested against other providers
or with many different client configurations. A Keycloak setup that issues no refresh token (or a client with offline
access disabled) should reproduce Google's behavior. Cases worth checking:

  1. Default config, account that already consented, no stored token: one redirect with prompt=consent, then login works.
  2. Default config, provider still sends no token after consent: login fails as before (no loop).
  3. REQUIRE_REFRESH_TOKEN=false, no token: login and /auth/refresh work.
  4. Stored token, re-login without a new token: the stored token survives and is still exchanged on /auth/refresh.
  5. Stored token revoked at the provider: the session ends.

Unit tests for these paths are in api/src/auth/drivers/baseoauth.test.ts (10 tests; openid.test.ts still passes).

Type of change

  • Bug fix (fixes an issue)
  • New feature (adds functionality)

Related issues

Fix #401

Checklists

Development

  • Lint rules pass locally
  • Application changes have been tested thoroughly
  • Automated tests covering modified code pass

Security

  • Security impact of change has been considered
  • Code follows company security practices and guidelines

The new option is off by default. With false, a user's d9 session is no longer checked against the provider when no
refresh token is stored, so removing the user at the provider does not end their d9 session until it expires; this is
stated in the docs. When a token is stored, it is still checked as before.

Network

  • Changes to network configurations have been reviewed
  • Any newly exposed public endpoints or data have gone through security review

No new endpoints or network configuration.

Code review

  • Pull request has a descriptive title and context useful to a reviewer. Screenshots or screencasts are attached as
    necessary
  • reviewers assigned
  • Pull request linked to task tracker where applicable

slyk added 2 commits October 15, 2026 16:13
…ID/OAuth2

When the identity provider returns no refresh token (e.g. Google when the
account already granted consent), login and /auth/refresh were rejected with
"No [OpenID] Session found". Add an opt-in provider option; with `false`, a
missing refresh token no longer rejects and the Directus session is
authoritative. A stored refresh token is still exchanged at the provider and a
revoked grant still ends the session. Default (`true`) keeps current behavior.

Refs LaWebcapsule#401

Signed-off-by: Kyrylo Kuzmytskyi <slykirill@gmail.com>
Address review on LaWebcapsule#401:

- getUserID() no longer overwrites a stored refresh token with null when
  the provider issues none on login (e.g. Google skipping consent); the
  stored token is still exchanged in refresh(). Empty updates are skipped
  to keep the LaWebcapsule#127 crash fix.
- With REQUIRE_REFRESH_TOKEN=true (default), a missing refresh token now
  throws InvalidTokenException, so the OpenID/OAuth2 callbacks retry once
  with prompt=consent instead of failing the login.

Refs LaWebcapsule#401

Signed-off-by: Kyrylo Kuzmytskyi <slykirill@gmail.com>
slyk added a commit to slyk/d9 that referenced this pull request Oct 15, 2026
…k only)

Signed-off-by: Kyrylo Kuzmytskyi <slykirill@gmail.com>
@louisdussarps

louisdussarps commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Hello @slyk,
Tested locally and everything works as expected!
For the record, one edge case I don't consider realistic: if a refresh token is already stored for a user and the provider is later reconfigured to stop issuing refresh tokens, the stale token is never replaced. Once it expires or is revoked, refresh and login keep failing with INVALID_TOKEN. This only happens after a deliberate provider reconfiguration, which is unlikely in practice, and manually clearing the stored tokens once fixes it.
No changes needed, PR approved.
Thanks for the work on this !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-login Google after the first consent: OpenID/OAuth2 login fails with "No [OpenID] Session found" when the provider returns no refresh token.

2 participants