From a1bb185d65b4c4ee0fbd464fc1313e6a9128da87 Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 30 Sep 2026 03:57:22 +0800 Subject: [PATCH 1/2] feat(w3ds-oidc-connector): send the eVault profile's email and name as standard claims so they survive any IdP --- .../src/app.integration.spec.ts | 72 +++++- services/w3ds-oidc-connector/src/app.ts | 8 + .../w3ds-oidc-connector/src/claims.spec.ts | 57 ++++- services/w3ds-oidc-connector/src/claims.ts | 35 ++- services/w3ds-oidc-connector/src/login.ts | 18 +- .../w3ds-oidc-connector/src/profile.spec.ts | 188 ++++++++++++++++ services/w3ds-oidc-connector/src/profile.ts | 207 ++++++++++++++++++ .../src/routes/discovery.ts | 3 + .../w3ds-oidc-connector/src/routes/token.ts | 3 +- .../w3ds-oidc-connector/src/test-utils.ts | 5 +- services/w3ds-oidc-connector/src/types.ts | 7 + .../w3ds-oidc-connector/src/views/portal.ts | 11 +- 12 files changed, 590 insertions(+), 24 deletions(-) create mode 100644 services/w3ds-oidc-connector/src/profile.spec.ts create mode 100644 services/w3ds-oidc-connector/src/profile.ts diff --git a/services/w3ds-oidc-connector/src/app.integration.spec.ts b/services/w3ds-oidc-connector/src/app.integration.spec.ts index 3d6462644..9a71a1511 100644 --- a/services/w3ds-oidc-connector/src/app.integration.spec.ts +++ b/services/w3ds-oidc-connector/src/app.integration.spec.ts @@ -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, @@ -43,6 +44,8 @@ let wallet: { publicKey: string; privateKey: string }; interface Upstream { certificates: () => Promise; hang: boolean; + /** The User envelopes the eVault holds. */ + profiles: Record[]; } let upstream: Upstream; @@ -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[]) => ({ + 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(), @@ -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(); @@ -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(); @@ -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), @@ -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( diff --git a/services/w3ds-oidc-connector/src/app.ts b/services/w3ds-oidc-connector/src/app.ts index 13042d96e..414fffaf9 100644 --- a/services/w3ds-oidc-connector/src/app.ts +++ b/services/w3ds-oidc-connector/src/app.ts @@ -21,6 +21,7 @@ import { userinfoRouter } from "./routes/userinfo.js"; import { w3dsRouter } from "./routes/w3ds.js"; import { discoveryRouter } from "./routes/discovery.js"; import { portalRouter } from "./routes/portal.js"; +import { createProfileReader, type ProfileReader } from "./profile.js"; import { CodeStore } from "./store/codes.js"; import { SessionStore } from "./store/sessions.js"; import { AccessTokenStore } from "./store/tokens.js"; @@ -40,6 +41,8 @@ export interface AppDeps { tokens: AccessTokenStore; bus: SessionBus; verifier: WalletVerifier; + /** Reads the user's eVault profile; never throws. */ + profiles: ProfileReader; /** HMAC key for developer portal session cookies. */ portalKey: Uint8Array; /** Milliseconds since the epoch; injectable for tests. */ @@ -78,6 +81,11 @@ export function createDeps( timeoutMs: config.upstreamTimeoutMs, jwksCacheMs: config.jwksCacheSeconds * 1000, }), + profiles: createProfileReader({ + registryUrl: config.registryUrl, + platformName: config.platformName, + timeoutMs: config.upstreamTimeoutMs, + }), portalKey: overrides.portalKey ?? portalKey(config), now: Date.now, ...overrides, diff --git a/services/w3ds-oidc-connector/src/claims.spec.ts b/services/w3ds-oidc-connector/src/claims.spec.ts index dc95a4da3..06b06a70d 100644 --- a/services/w3ds-oidc-connector/src/claims.spec.ts +++ b/services/w3ds-oidc-connector/src/claims.spec.ts @@ -24,9 +24,18 @@ describe("sanitizeUsername", () => { }); }); +const PROFILE = { + email: "ada@example.org", + name: "Ada Lovelace", + givenName: "Ada", + familyName: "Lovelace", +}; +const WITH_PROFILE = { ...IDENTITY, profile: PROFILE }; +const ALL = ["openid", "profile", "email"]; + describe("claims", () => { - it("omits email unless the client asks for a synthetic one", () => { - expect(userClaims(IDENTITY, { syntheticEmail: false })).toEqual({ + it("carries the eName in sub and preferred_username only", () => { + expect(userClaims(IDENTITY, { syntheticEmail: false }, ["openid"])).toEqual({ sub: IDENTITY.eName, preferred_username: "e4d1c2b0-5a6f-4c1e-9b1d-3f2a7c8e9d10", amr: ["hwk"], @@ -34,17 +43,56 @@ describe("claims", () => { }); }); - it("adds an undeliverable, unverified email for clients that need one", () => { - expect(userClaims(IDENTITY, { syntheticEmail: true })).toMatchObject({ + it("sends the profile email, unverified, for the email scope", () => { + expect( + userClaims(WITH_PROFILE, { syntheticEmail: false }, ["openid", "email"]), + ).toMatchObject({ email: "ada@example.org", email_verified: false }); + }); + + it("sends no email without the email scope or the client flag", () => { + const claims = userClaims(WITH_PROFILE, { syntheticEmail: false }, [ + "openid", + "profile", + ]); + expect(claims).not.toHaveProperty("email"); + expect(claims).not.toHaveProperty("email_verified"); + }); + + it("sends nothing for the email scope when the profile has no email", () => { + expect( + userClaims(IDENTITY, { syntheticEmail: false }, ALL), + ).not.toHaveProperty("email"); + }); + + it("falls back to an undeliverable email for clients that need one", () => { + expect(userClaims(IDENTITY, { syntheticEmail: true }, ["openid"])).toMatchObject({ email: "e4d1c2b0-5a6f-4c1e-9b1d-3f2a7c8e9d10@w3ds.invalid", email_verified: false, }); }); + it("prefers the real email over the synthetic one", () => { + expect( + userClaims(WITH_PROFILE, { syntheticEmail: true }, ["openid"]), + ).toMatchObject({ email: "ada@example.org" }); + }); + + it("sends names only for the profile scope", () => { + expect(userClaims(WITH_PROFILE, { syntheticEmail: false }, ALL)).toMatchObject({ + name: "Ada Lovelace", + given_name: "Ada", + family_name: "Lovelace", + }); + expect( + userClaims(WITH_PROFILE, { syntheticEmail: false }, ["openid", "email"]), + ).not.toHaveProperty("name"); + }); + it("builds standard ID token claims", () => { const claims = idTokenClaims({ identity: IDENTITY, client: { clientId: "keycloak", syntheticEmail: false }, + scope: ["openid"], issuer: "https://id.example", nonce: "n-1", nowSeconds: 2000, @@ -65,6 +113,7 @@ describe("claims", () => { const claims = idTokenClaims({ identity: IDENTITY, client: { clientId: "keycloak", syntheticEmail: false }, + scope: ["openid"], issuer: "https://id.example", nowSeconds: 2000, ttlSeconds: 300, diff --git a/services/w3ds-oidc-connector/src/claims.ts b/services/w3ds-oidc-connector/src/claims.ts index cf4c41648..6e5a4240d 100644 --- a/services/w3ds-oidc-connector/src/claims.ts +++ b/services/w3ds-oidc-connector/src/claims.ts @@ -20,29 +20,50 @@ export function sanitizeUsername(eName: string): string { /** * The claims describing the user, shared by the ID token and /userinfo. - * The `.invalid` TLD is reserved (RFC 2606), so the synthetic email can never - * receive mail, and `email_verified: false` stops IdPs linking accounts on it. + * + * Only standard claims, because an IdP between the connector and the app + * replaces `sub` with its own ID and passes on little else. The eName + * survives as `preferred_username`, which is lossless for UUID eNames. + * + * Email and names come from the user's eVault profile. The profile is + * self-asserted, so `email_verified` is always false: IdPs must not link + * accounts on it. When there is no email to give, clients that need one get + * a synthetic address under the reserved `.invalid` TLD (RFC 2606), which + * can never receive mail. */ export function userClaims( identity: Identity, client: Pick, + scope: readonly string[], ): Record { const username = sanitizeUsername(identity.eName); + const profile = identity.profile ?? {}; + const email = + scope.includes("email") || client.syntheticEmail + ? (profile.email ?? + (client.syntheticEmail ? `${username}@w3ds.invalid` : undefined)) + : undefined; + const names = scope.includes("profile") + ? { + ...(profile.name && { name: profile.name }), + ...(profile.givenName && { given_name: profile.givenName }), + ...(profile.familyName && { family_name: profile.familyName }), + } + : {}; return { sub: identity.eName, preferred_username: username, + ...names, amr: identity.amr, auth_time: identity.authTime, - ...(client.syntheticEmail && { - email: `${username}@w3ds.invalid`, - email_verified: false, - }), + ...(email && { email, email_verified: false }), }; } export function idTokenClaims(options: { identity: Identity; client: Pick; + scope: readonly string[]; issuer: string; nonce?: string; nowSeconds: number; @@ -54,6 +75,6 @@ export function idTokenClaims(options: { iat: options.nowSeconds, exp: options.nowSeconds + options.ttlSeconds, ...(options.nonce !== undefined && { nonce: options.nonce }), - ...userClaims(options.identity, options.client), + ...userClaims(options.identity, options.client, options.scope), }; } diff --git a/services/w3ds-oidc-connector/src/login.ts b/services/w3ds-oidc-connector/src/login.ts index d0335e064..a678f8639 100644 --- a/services/w3ds-oidc-connector/src/login.ts +++ b/services/w3ds-oidc-connector/src/login.ts @@ -4,6 +4,7 @@ * the browser that started it. */ +import { normalizeEName } from "@metastate-foundation/auth/ename"; import type { AppDeps } from "./app.js"; import { withQuery } from "./http/errors.js"; import { log } from "./log.js"; @@ -49,11 +50,17 @@ export async function completeWalletLogin( if (lookup.state === "expired") return "expired"; if (lookup.session.status !== "pending") return "not_pending"; - const result = await deps.verifier({ - eName, - payload: sessionId, - signature, - }); + // Read the profile while the signature is checked; it is used only if + // the signature proves this same eName. + const { purpose } = lookup.session; + const wantsProfile = + purpose.kind === "oidc" && + purpose.request.scope.some((s) => s === "email" || s === "profile"); + const claimedEName = normalizeEName(eName); + const [result, profile] = await Promise.all([ + deps.verifier({ eName, payload: sessionId, signature }), + wantsProfile ? deps.profiles(claimedEName) : undefined, + ]); if (!result.valid || !result.eName) { log.info(`wallet signature rejected: ${result.error ?? "invalid"}`); deps.bus.emit(sessionId, "attempt_failed"); @@ -67,6 +74,7 @@ export async function completeWalletLogin( eName: result.eName, amr: [result.keyType === "hardware" ? "hwk" : "swk"], authTime: Math.floor(now / 1000), + ...(profile && result.eName === claimedEName && { profile }), }, now, ); diff --git a/services/w3ds-oidc-connector/src/profile.spec.ts b/services/w3ds-oidc-connector/src/profile.spec.ts new file mode 100644 index 000000000..5f3653511 --- /dev/null +++ b/services/w3ds-oidc-connector/src/profile.spec.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { + PROFESSIONAL_PROFILE_ONTOLOGY, + USER_ONTOLOGY, + createProfileReader, + profileFromEnvelopes, +} from "./profile.js"; + +const ENAME = "@e4d1c2b0-5a6f-4c1e-9b1d-3f2a7c8e9d10"; +const REGISTRY = "http://registry.test"; +const VAULT = "http://vault.test"; + +const edges = (...parsed: unknown[]) => ({ + edges: parsed.map((value, i) => ({ node: { id: `m${i}`, parsed: value } })), +}); + +describe("profileFromEnvelopes", () => { + it("reads the primary User envelope", () => { + expect( + profileFromEnvelopes(ENAME, { + user: edges( + { displayName: "Legacy", email: "old@example.org" }, + { + ename: ENAME, + displayName: "Ada Lovelace", + givenName: "Ada", + familyName: "Lovelace", + email: " ada@example.org ", + }, + ), + }), + ).toEqual({ + email: "ada@example.org", + name: "Ada Lovelace", + givenName: "Ada", + familyName: "Lovelace", + }); + }); + + it("falls back to the professional profile email", () => { + expect( + profileFromEnvelopes(ENAME, { + user: edges({ ename: ENAME, displayName: "Ada" }), + professional: edges({ email: "work@example.org" }), + }), + ).toEqual({ email: "work@example.org", name: "Ada" }); + }); + + it("ignores malformed emails and placeholder names", () => { + expect( + profileFromEnvelopes(ENAME, { + user: edges({ + ename: ENAME, + displayName: ENAME, + firstName: "Ada", + lastName: "L", + email: "not an email", + }), + }), + ).toEqual({ name: "Ada L", givenName: "Ada", familyName: "L" }); + }); + + it("returns nothing for an empty or missing vault", () => { + expect(profileFromEnvelopes(ENAME, { user: null })).toEqual({}); + expect(profileFromEnvelopes(ENAME, {})).toEqual({}); + }); +}); + +type Handler = (url: URL, init: RequestInit) => Response | Promise; + +function fakeFetch(handler: Handler) { + const calls: { url: URL; init: RequestInit }[] = []; + const impl = (async (input: string | URL, init: RequestInit = {}) => { + const url = new URL(String(input)); + calls.push({ url, init }); + return handler(url, init); + }) as typeof fetch; + return { impl, calls }; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +function upstream(graphql: (auth: string) => Response): Handler { + let issued = 0; + return (url, init) => { + if (url.pathname === "/resolve") return json({ uri: VAULT }); + if (url.pathname === "/platforms/certification") { + issued += 1; + return json({ token: `t${issued}` }); + } + const headers = init.headers as Record; + return graphql(headers.Authorization); + }; +} + +const reader = (impl: typeof fetch) => + createProfileReader({ + registryUrl: REGISTRY, + platformName: "Test Login", + timeoutMs: 1000, + fetch: impl, + }); + +describe("createProfileReader", () => { + it("queries the owner's vault for both ontologies", async () => { + const { impl, calls } = fakeFetch( + upstream(() => + json({ + data: { + user: edges({ ename: ENAME, email: "ada@example.org" }), + professional: edges(), + }, + }), + ), + ); + expect(await reader(impl)(ENAME)).toEqual({ email: "ada@example.org" }); + + const query = calls.find((c) => c.url.pathname === "/graphql")!; + expect(query.url.origin).toBe(VAULT); + expect(query.init.headers).toMatchObject({ + Authorization: "Bearer t1", + "X-ENAME": ENAME, + }); + expect(JSON.parse(String(query.init.body)).variables).toEqual({ + user: USER_ONTOLOGY, + professional: PROFESSIONAL_PROFILE_ONTOLOGY, + }); + expect( + calls.find((c) => c.url.pathname === "/resolve")!.url.searchParams.get("w3id"), + ).toBe(ENAME); + }); + + it("reuses the platform token across logins", async () => { + const { impl, calls } = fakeFetch(upstream(() => json({ data: {} }))); + const read = reader(impl); + await read(ENAME); + await read(ENAME); + expect( + calls.filter((c) => c.url.pathname === "/platforms/certification"), + ).toHaveLength(1); + }); + + it("gets a fresh token once when the eVault refuses one", async () => { + const { impl } = fakeFetch( + upstream((auth) => + auth === "Bearer t1" + ? json({ error: "expired" }, 401) + : json({ data: { user: edges({ email: "ada@example.org" }) } }), + ), + ); + expect(await reader(impl)(ENAME)).toEqual({ email: "ada@example.org" }); + }); + + it("returns an empty profile on any failure", async () => { + const failing = fakeFetch(() => json({}, 404)); + expect(await reader(failing.impl)(ENAME)).toEqual({}); + + const refused = fakeFetch(upstream(() => json({}, 401))); + expect(await reader(refused.impl)(ENAME)).toEqual({}); + + const broken = fakeFetch(() => { + throw new Error("network down"); + }); + expect(await reader(broken.impl)(ENAME)).toEqual({}); + }); + + it("gives up at the deadline", async () => { + const { impl } = fakeFetch( + (_url, init) => + new Promise((_, reject) => { + init.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + const read = createProfileReader({ + registryUrl: REGISTRY, + platformName: "Test Login", + timeoutMs: 20, + fetch: impl, + }); + expect(await read(ENAME)).toEqual({}); + }); +}); diff --git a/services/w3ds-oidc-connector/src/profile.ts b/services/w3ds-oidc-connector/src/profile.ts new file mode 100644 index 000000000..a6bffefbf --- /dev/null +++ b/services/w3ds-oidc-connector/src/profile.ts @@ -0,0 +1,207 @@ +/** + * Reads the profile a user keeps in their own eVault, so IdPs get a real + * email address and name instead of values derived from the eName. + * + * Everything here is best effort. The profile is self-asserted, so it never + * decides who the user is, and an unreachable Registry or eVault must not + * stop a login: any failure yields an empty profile. + */ + +import { log } from "./log.js"; + +/** The W3DS `User` ontology. */ +export const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +/** The W3DS `ProfessionalProfile` ontology. */ +export const PROFESSIONAL_PROFILE_ONTOLOGY = + "550e8400-e29b-41d4-a716-446655440009"; + +export interface Profile { + email?: string; + name?: string; + givenName?: string; + familyName?: string; +} + +export type ProfileReader = (eName: string) => Promise; + +const PROFILE_QUERY = ` + query Profile($user: ID!, $professional: ID!) { + user: metaEnvelopes(filter: { ontologyId: $user }, first: 20) { + edges { node { id parsed } } + } + professional: metaEnvelopes(filter: { ontologyId: $professional }, first: 20) { + edges { node { id parsed } } + } + } +`; + +const MAX_EMAIL_LENGTH = 254; +const MAX_NAME_LENGTH = 256; +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type Parsed = Record; +type Connection = { edges?: { node?: { parsed?: unknown } | null }[] } | null; + +function text(value: unknown, max: number): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed && trimmed.length <= max ? trimmed : undefined; +} + +function email(parsed: Parsed | undefined): string | undefined { + const value = text(parsed?.email, MAX_EMAIL_LENGTH); + return value && EMAIL.test(value) ? value : undefined; +} + +/** + * An eVault can hold several envelopes of one ontology (legacy replicas next + * to the primary). The primary is the one whose `ename` names the owner. + */ +function primary(connection: Connection, eName: string): Parsed | undefined { + const parsed = (connection?.edges ?? []) + .map((edge) => edge?.node?.parsed) + .filter( + (value): value is Parsed => + typeof value === "object" && value !== null, + ); + return parsed.find((value) => value.ename === eName) ?? parsed[0]; +} + +/** + * Profiles are written by many platforms and disagree on name fields. A + * `displayName` equal to the eName is a placeholder, not a name. + */ +function names(parsed: Parsed | undefined, eName: string): Omit { + if (!parsed) return {}; + const givenName = + text(parsed.givenName, MAX_NAME_LENGTH) ?? + text(parsed.firstName, MAX_NAME_LENGTH); + const familyName = + text(parsed.familyName, MAX_NAME_LENGTH) ?? + text(parsed.lastName, MAX_NAME_LENGTH); + const displayName = text(parsed.displayName, MAX_NAME_LENGTH); + const joined = [givenName, familyName].filter(Boolean).join(" "); + const name = + displayName && displayName !== eName ? displayName : joined || undefined; + return { + ...(name && { name }), + ...(givenName && { givenName }), + ...(familyName && { familyName }), + }; +} + +/** The profile carried by the envelopes of one eVault. */ +export function profileFromEnvelopes( + eName: string, + data: { user?: Connection; professional?: Connection }, +): Profile { + const user = primary(data.user ?? null, eName); + const professional = primary(data.professional ?? null, eName); + const fromUser = names(user, eName); + const address = email(user) ?? email(professional); + return { + ...(address && { email: address }), + ...(fromUser.name ? fromUser : names(professional, eName)), + }; +} + +export function createProfileReader(options: { + registryUrl: string; + platformName: string; + timeoutMs: number; + fetch?: typeof fetch; +}): ProfileReader { + const fetchImpl = options.fetch ?? fetch; + let token: Promise | undefined; + + // The Registry issues these to any platform name and they live for a + // year, so one is reused until an eVault refuses it. + const platformToken = (signal: AbortSignal): Promise => { + if (token) return token; + const pending = (async () => { + const res = await fetchImpl( + new URL("/platforms/certification", options.registryUrl), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ platform: options.platformName }), + signal, + }, + ); + if (!res.ok) throw new Error(`certification returned ${res.status}`); + const body = (await res.json()) as { token?: unknown }; + if (typeof body.token !== "string" || !body.token) { + throw new Error("certification returned no token"); + } + return body.token; + })(); + // Never keep a failure: the next login asks again. + pending.catch(() => { + if (token === pending) token = undefined; + }); + token = pending; + return pending; + }; + + const resolve = async (eName: string, signal: AbortSignal) => { + const url = new URL("/resolve", options.registryUrl); + url.searchParams.set("w3id", eName); + const res = await fetchImpl(url, { signal }); + if (!res.ok) throw new Error(`resolve returned ${res.status}`); + const body = (await res.json()) as { uri?: unknown; evaultUrl?: unknown }; + const vault = body.uri ?? body.evaultUrl; + if (typeof vault !== "string" || !vault) { + throw new Error("resolve returned no eVault"); + } + return vault; + }; + + const query = async ( + vault: string, + eName: string, + signal: AbortSignal, + retry: boolean, + ): Promise => { + const bearer = await platformToken(signal); + const res = await fetchImpl(new URL("/graphql", vault), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearer}`, + "X-ENAME": eName, + }, + body: JSON.stringify({ + query: PROFILE_QUERY, + variables: { + user: USER_ONTOLOGY, + professional: PROFESSIONAL_PROFILE_ONTOLOGY, + }, + }), + signal, + }); + if ((res.status === 401 || res.status === 403) && retry) { + token = undefined; + return query(vault, eName, signal, false); + } + if (!res.ok) throw new Error(`eVault returned ${res.status}`); + // One ontology failing still leaves the other's data usable. + const body = (await res.json()) as { + data?: { user?: Connection; professional?: Connection } | null; + }; + return profileFromEnvelopes(eName, body.data ?? {}); + }; + + return async (eName) => { + const signal = AbortSignal.timeout(options.timeoutMs); + try { + const vault = await resolve(eName, signal); + return await query(vault, eName, signal, true); + } catch (error) { + log.info( + `could not read the profile of ${eName}:`, + error instanceof Error ? error.message : error, + ); + return {}; + } + }; +} diff --git a/services/w3ds-oidc-connector/src/routes/discovery.ts b/services/w3ds-oidc-connector/src/routes/discovery.ts index 7dcf8b50a..464dba6ee 100644 --- a/services/w3ds-oidc-connector/src/routes/discovery.ts +++ b/services/w3ds-oidc-connector/src/routes/discovery.ts @@ -14,6 +14,9 @@ export const SUPPORTED_CLAIMS = [ "nonce", "amr", "preferred_username", + "name", + "given_name", + "family_name", "email", "email_verified", ]; diff --git a/services/w3ds-oidc-connector/src/routes/token.ts b/services/w3ds-oidc-connector/src/routes/token.ts index 8dee955f7..3745467da 100644 --- a/services/w3ds-oidc-connector/src/routes/token.ts +++ b/services/w3ds-oidc-connector/src/routes/token.ts @@ -64,6 +64,7 @@ export function tokenRouter(deps: AppDeps): Router { idTokenClaims({ identity: record.identity, client, + scope: record.scope, issuer: deps.config.issuer, nonce: record.nonce, nowSeconds, @@ -72,7 +73,7 @@ export function tokenRouter(deps: AppDeps): Router { ); const accessToken = deps.tokens.issue( client.clientId, - userClaims(record.identity, client), + userClaims(record.identity, client, record.scope), now, ); deps.codes.recordAccessToken(body.code, accessToken); diff --git a/services/w3ds-oidc-connector/src/test-utils.ts b/services/w3ds-oidc-connector/src/test-utils.ts index ef71c1e1d..eee370672 100644 --- a/services/w3ds-oidc-connector/src/test-utils.ts +++ b/services/w3ds-oidc-connector/src/test-utils.ts @@ -59,7 +59,10 @@ export async function testApp( jwk: JSON.stringify(await generateSigningJwk()), production: false, }); - const deps = createDeps(config, keys, repository, rest); + const deps = createDeps(config, keys, repository, { + profiles: async () => ({}), + ...rest, + }); return { deps, repository, app: createApp(deps) }; } diff --git a/services/w3ds-oidc-connector/src/types.ts b/services/w3ds-oidc-connector/src/types.ts index 8db778f6c..dc17ce398 100644 --- a/services/w3ds-oidc-connector/src/types.ts +++ b/services/w3ds-oidc-connector/src/types.ts @@ -1,3 +1,5 @@ +import type { Profile } from "./profile.js"; + /** Authentication method references (RFC 8176) the connector can assert. */ export type Amr = "hwk" | "swk"; @@ -8,6 +10,11 @@ export interface Identity { amr: Amr[]; /** Seconds since the epoch. */ authTime: number; + /** + * What the user's eVault profile says about them. Self-asserted, so it + * describes the user but never identifies them. + */ + profile?: Profile; } /** The parameters of a validated /authorize request, carried to /token. */ diff --git a/services/w3ds-oidc-connector/src/views/portal.ts b/services/w3ds-oidc-connector/src/views/portal.ts index c5d68d919..e23fe0b96 100644 --- a/services/w3ds-oidc-connector/src/views/portal.ts +++ b/services/w3ds-oidc-connector/src/views/portal.ts @@ -176,7 +176,7 @@ export function portalHomePage( ${clientAvatar(client)}${escapeHtml(client.name)} ${copyField(client.clientId, { label: "client ID", field: "client-id" })} ${client.redirectUris.length} -${client.syntheticEmail ? 'Synthetic email' : 'Standard'} +${client.syntheticEmail ? 'Always email' : 'Standard'} ${shortDate(client.createdAt)} ${shortDate(client.lastUsedAt)} `, @@ -242,7 +242,7 @@ function clientFields(ctx: PortalContext, values: ClientFormValues): string {
+The user's email from their eVault profile is sent whenever your identity provider requests the email scope. Turn this on if your identity provider refuses users without an email: the claim is then always sent, and users with no email in their profile get <username>@w3ds.invalid, which can never receive mail. Profile emails are self-asserted, so email_verified is always false.
`; } @@ -266,7 +266,7 @@ ${csrfField(ctx.session)} /** Everything an identity provider needs to connect, each value copyable. */ function connectionPanel(issuer: string, client: ClientRecord): string { - const scope = client.syntheticEmail ? "openid profile email" : "openid profile"; + const scope = "openid profile email"; const copy = (label: string, field: string, value: string) => copyField(value, { label, field }); const rows: [string, string][] = [ @@ -283,7 +283,10 @@ function connectionPanel(issuer: string, client: ClientRecord): string { ], ["PKCE", "Required, method S256"], ["ID token signing", "ES256"], - ["User identifier", "Link accounts on the sub claim (the eName)"], + [ + "User identifier", + "Link accounts on sub (the eName). Apps behind your identity provider get the eName as preferred_username", + ], ]; return `

Connection details

Most identity providers only need the discovery URL, client ID and client secret.

From 7a926c25cd7fa1d8ed4792172e2900f3844ee90d Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 30 Sep 2026 03:58:10 +0800 Subject: [PATCH 2/2] docs(w3ds-oidc-connector): explain carrying the eName through any IdP and the unverified profile email --- docs/docs/Services/OIDC-Connector.md | 47 ++++++++++++++++++--- docs/docs/Services/OIDC-Developer-Portal.md | 2 +- docs/docs/Services/OIDC-Provider-Guides.md | 25 +++++++---- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/docs/docs/Services/OIDC-Connector.md b/docs/docs/Services/OIDC-Connector.md index 9455138cb..00d69a246 100644 --- a/docs/docs/Services/OIDC-Connector.md +++ b/docs/docs/Services/OIDC-Connector.md @@ -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 @@ -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` | `@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 `@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. ::: @@ -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(_0_)`, 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 \ + --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 `) 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 diff --git a/docs/docs/Services/OIDC-Developer-Portal.md b/docs/docs/Services/OIDC-Developer-Portal.md index a07764469..2851eba94 100644 --- a/docs/docs/Services/OIDC-Developer-Portal.md +++ b/docs/docs/Services/OIDC-Developer-Portal.md @@ -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: @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 `@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. diff --git a/docs/docs/Services/OIDC-Provider-Guides.md b/docs/docs/Services/OIDC-Provider-Guides.md index ff035267e..06bdcd52c 100644 --- a/docs/docs/Services/OIDC-Provider-Guides.md +++ b/docs/docs/Services/OIDC-Provider-Guides.md @@ -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 | | --- | --- | --- | --- | @@ -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 @@ -45,20 +47,24 @@ Tested with Rauthy 0.36. 1. **Create a client.** In the portal, create a client with: - Redirect URI: `https:///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=`. :::tip Testing against a local connector @@ -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.