Skip to content

Support configurable OIDC authority separate from JMAP server URL #4388

@nka11

Description

@nka11

Context

When deploying Twake Mail against a third-party JMAP server (e.g. Stalwart) that delegates authentication to an external OIDC identity provider (e.g. Kanidm, Keycloak, Authentik), the OIDC issuer lives on a completely different origin than the JMAP server.

Example topology:

Component URL
JMAP server (Stalwart) https://mail.example.org
OIDC Identity Provider (Kanidm) https://id.example.org
Twake Mail web https://webmail.example.org

The JMAP server validates Bearer tokens issued by the IdP (via its userinfo endpoint), but does not serve /.well-known/openid-configuration itself — that endpoint lives on the IdP.

Current behavior

Twake Mail assumes the OIDC authority can be discovered from the JMAP server URL:

  1. LoginController._checkOIDCIsAvailable() sends a WebFinger / OIDC discovery request to the SERVER_URL origin.
  2. Since the JMAP server does not expose /.well-known/openid-configuration, the request returns 404 or times out.
  3. The app falls back to basic auth, which may not be available (the server only accepts Bearer tokens via OIDC).

This means there is currently no way to use Twake Mail with a JMAP server whose OIDC provider is on a different domain, unless the JMAP server proxies the OIDC discovery endpoint (which is unusual and creates coupling).

Expected behavior

Allow operators to configure the OIDC authority independently from the JMAP server URL via env.file:

SERVER_URL=https://mail.example.org
AUTHORITY_OIDC=https://id.example.org/oauth2/openid/tmail-web
WEB_OIDC_CLIENT_ID=tmail-web
OIDC_SCOPES=openid,profile,email
DOMAIN_REDIRECT_URL=https://webmail.example.org

When AUTHORITY_OIDC is set:

  1. Skip WebFinger/OIDC discovery against SERVER_URL entirely.
  2. Build the OIDCConfiguration directly from the configured authority, client ID, and scopes.
  3. Proceed with the standard Authorization Code + PKCE flow against the configured IdP.
  4. Present the resulting access token as a Bearer token to the JMAP server.

When AUTHORITY_OIDC is not set, the current WebFinger discovery behavior is preserved (no breaking change).

Authenticated flow (with AUTHORITY_OIDC)

┌────────────┐     ┌──────────────────┐     ┌──────────────┐
│ Twake Mail │     │  OIDC IdP        │     │ JMAP Server  │
│ (browser)  │     │  (Kanidm, etc.)  │     │ (Stalwart)   │
└─────┬──────┘     └────────┬─────────┘     └──────┬───────┘
      │                     │                      │
      │  1. Read AUTHORITY_OIDC from env.file      │
      │  2. Fetch .well-known/openid-configuration │
      │ ──────────────────► │                      │
      │  3. OIDC config     │                      │
      │ ◄────────────────── │                      │
      │                     │                      │
      │  4. Redirect user to authorization endpoint│
      │ ──────────────────► │                      │
      │  5. User logs in    │                      │
      │  6. Auth code + redirect back              │
      │ ◄────────────────── │                      │
      │                     │                      │
      │  7. Exchange code for access_token (PKCE)  │
      │ ──────────────────► │                      │
      │  8. access_token + refresh_token           │
      │ ◄────────────────── │                      │
      │                     │                      │
      │  9. JMAP requests with Bearer access_token │
      │ ─────────────────────────────────────────► │
      │ 10. Stalwart validates token via IdP       │
      │     userinfo endpoint                      │
      │                     │ ◄──────────────────── │
      │                     │  token introspection  │
      │                     │ ────────────────────► │
      │ 11. JMAP response                          │
      │ ◄───────────────────────────────────────── │

Implementation notes

The changes required are minimal and fully backward-compatible. The key touch points are:

  1. app_config.dart — expose AUTHORITY_OIDC from env.file
  2. login_controller.dart — in _checkOIDCIsAvailable(), when authorityOidc is non-empty, directly construct OIDCConfiguration and skip WebFinger discovery
  3. handle_web_finger_to_get_token_extension.dart — same bypass for the token refresh / re-authentication path
  4. oidc_configuration_cache_manager.dart — prefer AUTHORITY_OIDC from env over the cached SharedPreferences value, so a config change takes effect without clearing app state
  5. Dockerfile — remove assets/env.file from the Flutter service worker cache manifest so that a volume-mounted env.file is always fetched fresh at runtime

We have a work-in-progress exploration of this approach in our fork: https://github.com/Deepthought-Solutions/tmail-flutter — the flow works end-to-end for the login path but we are still debugging edge cases (token refresh, re-authentication after expiry). Happy to collaborate on a PR if the approach looks acceptable.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions