Skip to main content

OIDC Connector

The W3DS OIDC Connector lets any OpenID Connect identity provider (IdP) offer "Log in with W3DS". It is an OIDC provider whose only login step is a signature from the user's eID wallet. Keycloak, Rauthy, Authentik, Zitadel, Auth0, Okta and similar products add it as an upstream provider, the same way they add "Log in with Google". The IdP needs no plugins or code changes, and neither do the apps behind it.

The hosted service runs at https://oidc.w3ds.metastate.foundation.

Discovery URLhttps://oidc.w3ds.metastate.foundation/.well-known/openid-configuration
Issuerhttps://oidc.w3ds.metastate.foundation
Developer portalhttps://oidc.w3ds.metastate.foundation/portal (the bare URL redirects here)

To use it:

  1. Sign in to the developer portal with your eID wallet and create a client.
  2. Add the connector to your IdP as described below, or follow a guide for your IdP.

Why it exists​

Organisations don't add login to each app. They run an IdP, and their apps trust it. Building W3DS into each IdP natively would mean a separate plugin per product:

  • Keycloak needs a Java extension.
  • Rauthy has no plugin system.
  • Hosted IdPs such as Auth0, Okta or Entra allow no custom server code.

Every IdP already supports upstream OIDC providers, so one connector that speaks OIDC brings W3DS login to all of them through configuration alone.

What it proves​

The connector proves one fact: this browser belongs to the holder of eName X. It returns that fact as a signed ID token.

It stores no users, passwords or profile data. At login it reads the user's name and email from their own eVault profile and passes them on as claims, but keeps nothing. Users, roles, groups and sessions stay in your IdP. The only data it keeps is the list of clients that developers register in the portal.

Your apps --OIDC--> Your IdP (Keycloak, Rauthy, …) --OIDC--> oidc.w3ds.metastate.foundation --w3ds://auth--> eID wallet
|
+--verify key--> W3DS Registry + eVault

Login flow​

  1. Your IdP redirects the browser to https://oidc.w3ds.metastate.foundation/authorize with your client ID, state, nonce and a PKCE S256 challenge.
  2. The connector shows a QR code. It checks the client and redirect URI, creates a random session S, and shows a QR code for w3ds://auth?redirect=…/w3ds/callback&session=S&platform=…&name=…&logo=…. platform and name carry the client's name, and logo its logo if it has one, so the wallet's approval card shows the application the user is signing in to (see Authentication). The login page shows the same name and logo. On a phone, the Open in eID Wallet button opens the same link.
  3. The wallet signs. It signs S with the user's P-256 key and posts {ename, session, signature} to the connector. On a phone it opens …/deeplink-login in the browser instead.
  4. The connector verifies and hands back a code. After verifying the signature (see below), it gives a one-time authorization code to the browser that started the login, and only that browser. The browser returns to your IdP.
  5. Your IdP finishes the login. It exchanges the code at /token, checks the ID token against /jwks, and logs the user in.

Endpoints​

All paths are relative to https://oidc.w3ds.metastate.foundation.

EndpointCalled byPurpose
GET /.well-known/openid-configurationIdPDiscovery
GET /jwksIdPPublic key for validating ID tokens
GET /authorizeBrowserStarts a login and shows the QR page
POST /tokenIdPExchanges a code and PKCE verifier for an ID token
GET /userinfoIdPReturns the same user claims as the ID token
POST /w3ds/callbackeID walletReceives the signed session
GET /deeplink-logineID wallet (mobile)Receives the signed session in the browser
GET /w3ds/events/:sessionBrowserTells the login page when the wallet has signed
GET /BrowserRedirects to the developer portal
GET /portalDevelopersDeveloper portal
GET /logo.png, /apple-touch-icon.png, /favicon.icoWallets, browsersThe connector's own logo, shown when a client has none
GET /healthzOrchestratorHealth check

Protocol details:

Grant typeAuthorization code only
PKCERequired, S256 only
Client authenticationclient_secret_basic or client_secret_post
ID token signingES256
Scopesopenid (required), profile, email
prompt=noneAlways fails with login_required: every login needs the wallet

Claims​

ClaimValueNotes
subThe eName, e.g. @e4d1c2b0-5a6f-…Stable identifier. Link accounts on this.
preferred_usernameThe eName without @, e.g. e4d1c2b0-5a6f-…eNames are UUIDs, so this is the eName exactly. It is how the eName reaches apps behind your IdP; see below.
name, given_name, family_nameFrom the user's eVault profileprofile scope. Left out when the profile has no name.
emailThe email in the user's eVault profileemail scope. Read from the User profile, falling back to the ProfessionalProfile. If the profile has none, clients with "My identity provider requires an email address" turned on get <username>@w3ds.invalid, which can never receive mail; other clients get no email.
email_verifiedfalseSent with email. The profile is written by the user and nobody has verified it.
amr["hwk"] or ["swk"]Hardware or software wallet key (RFC 8176). See the note below.
iss, aud, exp, iat, auth_time, nonceStandard OIDC values
The email is not verified

The user writes their own profile, so the email is whatever they put there. Never let your IdP link or merge accounts because an email matches, whether that's called auto-link, trust email or link by email. Someone could put another person's address in their profile and take over that person's account. Link accounts on the IdP's federated user ID, which it derives from sub.

The profile is read from the user's eVault during login. If the Registry or eVault is slow or unreachable, the login still succeeds, just without the name or the real email.

caution

amr is a hint, not an attestation. It is inferred from how the wallet encoded its signature: hardware keys use multibase base58btc and software keys use base64. Neither the wallet nor the Registry attests the key type, so do not use hwk as a security boundary.

Security properties​

  • The connector fails closed.
    • An eName with no key binding certificate is rejected.
    • Registry and eVault calls time out after 5 seconds, and a timeout means the login fails.
    • Certificates are never cached, so a revoked or rotated key takes effect on the next login.
  • Signatures are checked against certified keys. The connector resolves the eName at the Registry, fetches its key binding certificates from the eVault's /whois, checks each certificate against the Registry's JWKS, and verifies the ECDSA P-256 / SHA-256 signature over the session ID with a certified key.
  • The login is bound to one browser. Anyone can read session S off a QR code and get a wallet to sign it. Only the browser that opened the QR page holds the HttpOnly cookie needed to collect the resulting code.
  • Everything is single use. Sessions and codes can each be used once. A replayed code is rejected, and it revokes the access tokens already issued from it. PKCE is mandatory.
  • Client secrets are generated by the connector (256-bit), stored only as hashes, and shown only once.

Connecting an identity provider​

Any identity provider that can add a generic OpenID Connect provider can use the connector. The IdP must support:

  • the authorization code flow with PKCE (S256), which the connector requires;
  • a confidential client that authenticates with client_secret_basic or client_secret_post;
  • validating ES256-signed ID tokens against a JWKS URL.

To connect one:

  1. Create a client. In the developer portal, create a client whose redirect URI is your IdP's callback URL. Your IdP shows this URL when you add an OpenID Connect provider.

  2. Add a provider in your IdP. Add an OpenID Connect provider. Depending on the product it may be called an external, upstream, social or federated identity provider, or an enterprise connection.

  3. Enter the connection values. The portal lists all of these with copy buttons:

    SettingValue
    Discovery URLhttps://oidc.w3ds.metastate.foundation/.well-known/openid-configuration
    Issuerhttps://oidc.w3ds.metastate.foundation
    Client ID and client secretFrom the portal
    Client authenticationClient secret over HTTP Basic (client_secret_basic), or in the request body (client_secret_post)
    PKCEOn, method S256
    Scopesopenid profile email
    Signature validationOn, using the JWKS URL from discovery

    If your IdP can't read the discovery document, enter the endpoints yourself: /authorize, /token, /userinfo and /jwks under the issuer.

  4. Map the claims.

    • Use sub as the stable user identifier, and link accounts on it.
    • Use preferred_username as the username. It is the eName without @.
    • Import email, name, given_name and family_name if you want them, but never link accounts on the email. It is unverified.
  5. Test it. Log in through your IdP. The client's Last used time in the portal updates once your IdP has exchanged a code.

Using the eName behind your IdP​

Your apps don't talk to the connector. They talk to your IdP, and every IdP issues its own sub: Keycloak's user ID, Rauthy's user ID, and so on. The eName in the connector's sub stops at the IdP. An app that keys users on its IdP's sub (the OIDC default) gets an ID that means nothing outside that IdP.

What an IdP does pass on is the standard profile claims. The eName travels as preferred_username, which every IdP stores as the user's username and sends to its own apps under the profile scope. To carry the eName through to an app:

  1. In your IdP, set the username from the connector's preferred_username. Most IdPs do this by default; some need a mapper (see the provider guides).
  2. In your app, request openid profile email from your IdP, and take the local username from preferred_username, not from sub. Turn off any option that hashes or prefixes the ID.
  3. Don't treat the username as proof. An IdP with local accounts or self-registration may let someone choose a username that looks like another person's eName. Apps that act on a user's W3DS identity, for example by reading their eVault, should use preferred_username only as a hint and have the user confirm the eName with their wallet once. The Nextcloud W3DS Connector app does this.

Example: Nextcloud behind an IdP​

Nextcloud's OpenID Connect user backend (user_oidc) by default names each user sha256(<provider id>_0_<sub>), which is why logins show a 64-character hex user ID. Configure the provider so the user ID is the eName instead:

occ user_oidc:provider <your-idp> \
--scope="openid profile email" \
--unique-uid=0 \
--mapping-uid=preferred_username \
--mapping-email=email

user_oidc pins an account to the (provider, sub) pair when it first sees it, so accounts created before this change keep their hex IDs. Delete them (occ user:delete <uid>) so they are recreated at the next login.

With the W3DS Connector app installed, users who arrive this way are asked to scan once with their wallet to connect their eName. After that, their Talk conversations sync as they do for users who sign in with W3DS directly.

For step-by-step instructions for specific products, such as Keycloak and Rauthy, see the OIDC Provider Guides.

Running your own connector​

The code lives in services/w3ds-oidc-connector. It is a single Node.js service backed by Postgres.

docker build -f docker/Dockerfile.w3ds-oidc-connector -t w3ds-oidc-connector .
docker run --rm --env-file connector.env w3ds-oidc-connector node dist/scripts/migrate.js
docker run -d --env-file connector.env -p 4200:4200 w3ds-oidc-connector

Settings:

VariableDefaultPurpose
W3DS_OIDC_ISSUERrequiredPublic origin, e.g. https://oidc.w3ds.metastate.foundation. Must have no path, because the wallet's mobile deep link drops it. Must be https in production.
W3DS_OIDC_DATABASE_URLrequiredPostgres connection string
DB_CA_CERTnoneCA certificate, if Postgres uses TLS
PUBLIC_REGISTRY_URLrequiredThe W3DS Registry, e.g. https://registry.w3ds.metastate.foundation
W3DS_OIDC_SIGNING_KEY_JWKephemeral in devES256 private JWK that signs ID tokens. Required in production. Generate one with pnpm --filter w3ds-oidc-connector generate-jwk.
W3DS_OIDC_PORTAL_SECRETephemeral in devAt least 32 characters. Signs portal sessions. Required in production.
W3DS_OIDC_PLATFORM_NAMEW3DS LoginShown in the wallet and on the login pages
W3DS_OIDC_CLIENT_CREATE_LIMIT10Clients one eName may create per hour
W3DS_OIDC_DOCS_URLhttps://docs.w3ds.metastate.foundation/docs/ServicesWhere the portal links for documentation and provider guides
W3DS_OIDC_PORT4200Listen port
W3DS_OIDC_SESSION_TTL_SECONDS300How long a QR code stays valid
W3DS_OIDC_CODE_TTL_SECONDS60How long a code can be exchanged
W3DS_OIDC_TOKEN_TTL_SECONDS300ID token and access token lifetime
W3DS_OIDC_UPSTREAM_TIMEOUT_MS5000Registry and eVault timeout
W3DS_OIDC_JWKS_CACHE_SECONDS300How long the Registry's JWKS is reused
W3DS_OIDC_TRUST_PROXYoffExpress trust proxy, e.g. 1 behind one ingress

Operating notes:

  • Migrations first. Run node dist/scripts/migrate.js (or pnpm --filter w3ds-oidc-connector migrate from a checkout) before each start. The service refuses to start while migrations are pending.
  • Single instance. Login sessions, codes and access tokens live in memory, so run one replica. A restart cancels logins in progress; users simply scan again. Clients live in Postgres and survive restarts.
  • Serve it at the root of its own origin, over TLS. Behind a reverse proxy, set W3DS_OIDC_TRUST_PROXY, and make sure the proxy does not buffer /w3ds/events (it is a server-sent event stream).
  • Health. /healthz reports the process only, not the Registry. An unreachable Registry makes logins fail closed rather than restarting the container.
  • Local testing. Some identity providers refuse to talk to a plain-http connector; see the provider guides for how to test with them locally.