Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions docs/docs/Services/OIDC-Connector.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Every IdP already supports upstream OIDC providers, so one connector that speaks

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. 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.
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
Expand Down Expand Up @@ -82,12 +82,19 @@ All paths are relative to `https://oidc.w3ds.metastate.foundation`.
| Claim | Value | Notes |
| --- | --- | --- |
| `sub` | The eName, e.g. `@e4d1c2b0-5a6f-…` | Stable identifier. **Link accounts on this.** |
| `preferred_username` | The eName without `@`, lowercased and sanitised | A suggestion only. Two eNames can sanitise to the same value. |
| `email` | `<username>@w3ds.invalid` | Only for clients with **"requires an email"** turned on. The `.invalid` domain can never receive mail. |
| `email_verified` | `false` | Sent with `email`. Stops IdPs from linking accounts by email. |
| `preferred_username` | The 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](#using-the-ename-behind-your-idp). |
| `name`, `given_name`, `family_name` | From the user's eVault profile | `profile` scope. Left out when the profile has no name. |
| `email` | The email in the user's eVault profile | `email` 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_verified` | `false` | Sent 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`, `nonce` | Standard OIDC values | |

:::warning 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.
:::
Expand Down Expand Up @@ -122,16 +129,42 @@ To connect one:
| Client ID and client secret | From the portal |
| Client authentication | Client secret over HTTP Basic (`client_secret_basic`), or in the request body (`client_secret_post`) |
| PKCE | On, method `S256` |
| Scopes | `openid profile`, plus `email` if the client has "requires an email" turned on |
| Scopes | `openid profile email` |
| Signature validation | On, 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 suggested username.
- Never trust the email or link accounts on it: it is synthetic and unverified.
- 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](./OIDC-Provider-Guides.md)).
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](https://github.com/nextcloud/user_oidc) (`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:

```bash
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](https://github.com/ensombl/nextcloud-w3ds-login) 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](./OIDC-Provider-Guides.md).

## Running your own connector
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/Services/OIDC-Developer-Portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ You can only finish signing in in the browser where you opened the QR code. The
- **Name:** shown to your users on the W3DS login page and as the title of the approval card in their eID wallet, e.g. your organisation or product. Up to 64 characters.
- **Logo URL** (optional): an `https` link to a square image (PNG, JPEG, WebP or SVG, at least 128×128 pixels). It is shown on the W3DS login page and in the eID wallet. Host it somewhere stable; if it fails to load, the connector's own logo is shown instead.
- **Redirect URIs:** one per line, up to 10. These are your IdP's callback URLs: the URL your IdP shows when you add an OpenID Connect provider. The [provider guides](./OIDC-Provider-Guides.md) list them for popular products. Redirect URIs must use `https`; plain `http` is allowed only on `localhost`, for development. They must not contain a fragment (`#`). The connector matches them exactly, character for character.
- **My identity provider requires an email address:** turn this on if your IdP refuses upstream logins without an email. The connector then sends `email: <username>@w3ds.invalid` with `email_verified: false`. These addresses can never receive mail and never match a real account.
- **My identity provider requires an email address:** turn this on if your IdP refuses upstream logins without an email. Normally the connector sends the email from the user's eVault profile only when the IdP requests the `email` scope. With this on, it always sends one, and users whose profile has no email get `<username>@w3ds.invalid`, which can never receive mail. `email_verified` is always `false`.
3. Click **Create client**.
4. The next page shows your **client ID** and **client secret**, each with a **Copy** button.

Expand Down
25 changes: 16 additions & 9 deletions docs/docs/Services/OIDC-Provider-Guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ sidebar_position: 4

Step-by-step instructions for connecting specific identity providers (IdPs) to the [W3DS OIDC Connector](./OIDC-Connector.md). The general steps, which work for any OpenID Connect IdP, are in [Connecting an identity provider](./OIDC-Connector.md#connecting-an-identity-provider).

Every guide starts the same way: sign in to the [developer portal](./OIDC-Developer-Portal.md) at `https://oidc.w3ds.metastate.foundation` and create a client. Each guide says which redirect URI to register and whether to turn on **"My identity provider requires an email address"**. The portal shows the client ID, the client secret (once) and every connection value, each with a copy button.
Every guide starts the same way: sign in to the [developer portal](./OIDC-Developer-Portal.md) at `https://oidc.w3ds.metastate.foundation` and create a client. Each guide says which redirect URI to register and whether to turn on **"My identity provider requires an email address"**. With that setting on, the `email` claim is always sent, even to an IdP that doesn't request the `email` scope. Users with no email in their eVault profile get a placeholder `@w3ds.invalid` address.

Every guide ends the same way too. Your IdP now knows the user's eName as their username, and apps behind it read it from `preferred_username`. See [Using the eName behind your IdP](./OIDC-Connector.md#using-the-ename-behind-your-idp). The portal shows the client ID, the client secret (once) and every connection value, each with a copy button.

| Provider | Redirect URI to register | Requires an email | Tested |
| --- | --- | --- | --- |
Expand All @@ -31,12 +33,12 @@ Tested with Keycloak 26.
4. **Set the advanced options:**
- Turn on **Use PKCE**, with method **S256**.
- Turn on **Validate signatures** and **Use JWKS URL**.
- Set **Scopes** to `openid profile`.
- Turn **Trust Email** off.
- Set **Scopes** to `openid profile email`.
- Turn **Trust Email** off. The email comes from the user's own profile and is unverified.
5. **Add mappers** under the provider's **Mappers** tab:
- **Username Template Importer**, with template `${CLAIM.preferred_username}`
- **Username Template Importer**, with template `${CLAIM.preferred_username}`, so apps get the eName as `preferred_username`
- **Attribute Importer**, from claim `sub` to user attribute `w3ds_ename`
6. **Handle email.** The connector sends Keycloak no email. Either make email optional in the realm's **User profile**, or keep the default first-login *Review profile* step so users add one themselves.
6. **Handle email.** The connector sends the email from the user's eVault profile, but not every profile has one. Either make email optional in the realm's **User profile**, or keep the default first-login *Review profile* step so users add one themselves. Don't add an authenticator that links existing accounts automatically by email.
7. **Test it.** Your realm's login page now shows **W3DS**. To skip Keycloak's login page, apps send `kc_idp_hint=w3ds`.

## Rauthy
Expand All @@ -45,20 +47,24 @@ Tested with Rauthy 0.36.

1. **Create a client.** In the portal, create a client with:
- Redirect URI: `https://<rauthy-host>/auth/v1/providers/callback`
- **"My identity provider requires an email address"** turned **on**. Rauthy refuses upstream logins that carry no email.
- **"My identity provider requires an email address"** turned **on**. Rauthy refuses upstream logins that carry no email, so users without an email in their profile get a placeholder address.
2. **Look up the provider.** In the Rauthy admin UI, go to **Providers**, then **Add New**, and leave the mode on **OIDC**. Enter the **Issuer URL** `https://oidc.w3ds.metastate.foundation` and click **Lookup**. Rauthy discovers the endpoints and turns PKCE on.
3. **Fill in the provider settings:**
- **Scope:** `openid profile email`
- **Client name:** e.g. `W3DS`
- **Client ID** and **Client secret:** from the portal
- Tick **client_secret_basic**
4. **Save**, then open the new provider. Tick **Enabled** and **Auto-Onboarding**, then save again.
4. **Save**, then open the new provider. Tick **Enabled** and **Auto-Onboarding**, then save again. Leave **Auto-Link** off.

:::note
Without **Auto-Onboarding**, Rauthy rejects every new W3DS user with "User not found". It only lets through users that already exist and have linked W3DS.
:::

5. **Link existing accounts (optional).** Existing Rauthy users can link W3DS from their account page. Rauthy rejects an upstream login whose email collides with an unlinked local account; the connector's `@w3ds.invalid` addresses never collide.
:::danger Leave Auto-Link off
Auto-Link attaches an upstream login to any local Rauthy user with the same email, and Rauthy doesn't check `email_verified` when it does this. The connector's email comes from the user's own profile, so with Auto-Link on, anyone could take over a Rauthy account by putting its email in their profile.
:::

5. **Link existing accounts (optional).** Existing Rauthy users can link W3DS from their account page. Rauthy refuses a W3DS login whose email matches a local account that isn't linked yet. That user links W3DS from their account page first, then signs in with it.
6. **Test it.** Rauthy's login page now shows the provider. To skip Rauthy's login page, apps send `idp_hint=<provider id>`.

:::tip Testing against a local connector
Expand All @@ -71,6 +77,7 @@ Authentik, Zitadel, Auth0, Okta, Microsoft Entra ID and other products that supp

- **PKCE.** The connector requires PKCE with `S256`. An IdP that cannot send a PKCE challenge to an upstream provider cannot use the connector; the login fails with `invalid_request` ("a PKCE S256 code_challenge is required").
- **Email.** If the IdP refuses users without an email, turn on **"My identity provider requires an email address"** for the client, and add the `email` scope.
- **Account linking.** Configure the IdP to identify users by the `sub` claim, not by email.
- **Account linking.** Configure the IdP to identify users by the `sub` claim, never by email. The email is unverified.
- **Username.** Set the IdP username from `preferred_username`, so apps behind the IdP receive the eName.

If you connect another product, please contribute a guide to this page.
72 changes: 70 additions & 2 deletions services/w3ds-oidc-connector/src/app.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createApp, createDeps } from "./app.js";
import { generateSigningJwk, loadSigningKeys } from "./keys.js";
import { s256 } from "./pkce.js";
import { createProfileReader } from "./profile.js";
import {
KEYCLOAK_REDIRECT,
REGISTRY,
Expand All @@ -43,6 +44,8 @@ let wallet: { publicKey: string; privateKey: string };
interface Upstream {
certificates: () => Promise<string[]>;
hang: boolean;
/** The User envelopes the eVault holds. */
profiles: Record<string, unknown>[];
}
let upstream: Upstream;

Expand All @@ -62,6 +65,17 @@ const upstreamFetch: typeof fetch = async (input, init) => {
if (url.origin === REGISTRY && url.pathname === "/.well-known/jwks.json") {
return Response.json({ keys: [registryKey.jwk] });
}
if (url.origin === REGISTRY && url.pathname === "/platforms/certification") {
return Response.json({ token: "platform-token" });
}
if (url.origin === EVAULT && url.pathname === "/graphql") {
const edges = (list: Record<string, unknown>[]) => ({
edges: list.map((parsed, i) => ({ node: { id: `p${i}`, parsed } })),
});
return Response.json({
data: { user: edges(upstream.profiles), professional: edges([]) },
});
}
if (url.origin === EVAULT && url.pathname === "/whois") {
return Response.json({
keyBindingCertificates: await upstream.certificates(),
Expand Down Expand Up @@ -94,6 +108,7 @@ beforeEach(async () => {
upstream = {
certificates: async () => [await certify(wallet.publicKey)],
hang: false,
profiles: [],
};
// Listen first: the issuer must be the port the socket actually got.
server = createServer();
Expand Down Expand Up @@ -125,11 +140,17 @@ async function buildDeps(issuer: string) {
timeoutMs: config.upstreamTimeoutMs,
fetch: upstreamFetch,
}),
profiles: createProfileReader({
registryUrl: REGISTRY,
platformName: config.platformName,
timeoutMs: config.upstreamTimeoutMs,
fetch: upstreamFetch,
}),
});
}

/** Steps 1-6: the IdP sends the browser to /authorize and gets the QR page. */
async function authorize() {
async function authorize(scope = "openid profile") {
const discovery = await (
await fetch(`${base}/.well-known/openid-configuration`)
).json();
Expand All @@ -138,7 +159,7 @@ async function authorize() {
client_id: "keycloak",
redirect_uri: KEYCLOAK_REDIRECT,
response_type: "code",
scope: "openid profile",
scope,
state: "idp-state",
nonce: "idp-nonce",
code_challenge: s256(VERIFIER),
Expand Down Expand Up @@ -252,6 +273,53 @@ describe("W3DS OIDC login", () => {
expect(await userinfo.json()).toMatchObject({ sub: ENAME });
});

it("passes the eVault profile on as standard claims", async () => {
upstream.profiles = [
{
ename: ENAME,
displayName: "Ada Lovelace",
givenName: "Ada",
familyName: "Lovelace",
email: "ada@example.org",
},
];
const { discovery, cookie, session, callback } = await authorize(
"openid profile email",
);
const stream = readEvents(`${base}/w3ds/events/${session}`, {
headers: { cookie },
until: (event) => event !== "pending",
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect((await walletPost(callback, session)).status).toBe(200);
const { events } = await stream;
const redirect = new URL(
(events.at(-1)!.data as { redirect: string }).redirect,
);
const tokens = await (
await token(discovery.token_endpoint, redirect.searchParams.get("code")!)
).json();
const { payload } = await jwtVerify(
tokens.id_token,
createRemoteJWKSet(new URL(discovery.jwks_uri)),
{ issuer: base, audience: "keycloak", algorithms: ["ES256"] },
);
const expected = {
sub: ENAME,
preferred_username: ENAME.slice(1),
name: "Ada Lovelace",
given_name: "Ada",
family_name: "Lovelace",
email: "ada@example.org",
email_verified: false,
};
expect(payload).toMatchObject(expected);
const userinfo = await fetch(discovery.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
expect(await userinfo.json()).toMatchObject(expected);
});

it("reports a hardware-key signature as amr hwk", async () => {
const { discovery, cookie, session, callback } = await authorize();
const { encodeBase58 } = await import(
Expand Down
Loading
Loading