Skip to content

Commit ac8b783

Browse files
feat(solid): get email and name from WebID profile card
Fetch the user's Solid profile document (Turtle) after login and extract email (vcard:hasEmail → vcard:value mailto:) and name (vcard:fn or foaf:name).
1 parent 4498241 commit ac8b783

2 files changed

Lines changed: 138 additions & 1 deletion

File tree

SOLID_INTEGRATION_PROGRESS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u
157157
- **solidcommunity.net**: The IdP dereferences the client_id URL; a localhost client_id is not reachable. Use a public URL for production.
158158
- **SOLID_OPENID_SCOPE**: Use `"openid webid offline_access"` for refresh token support when the IdP supports it.
159159

160+
### 17. Solid Profile (WebID Card) Email and Name
161+
- **Status**: Complete
162+
- **Details**:
163+
- **Profile fetch**: After Solid login, when `userinfo.webid` and an access token is available, we fetch the user's WebID profile document (Turtle) from the profile URL derived from the WebID (strip fragment, e.g. `https://pod.example.com/profile/card#me``https://pod.example.com/profile/card`). Request uses `Authorization: Bearer` and `Accept: text/turtle` with a 5s timeout; PROXY is respected.
164+
- **Email and name extraction**: We parse the Turtle with **n3** (no regex). Email is read from the subject's `vcard:hasEmail` → object node → that node's `vcard:value` (mailto: URI); name from `vcard:fn` (preferred) or `foaf:name`. Uses **N3.Store** and **DataFactory.namedNode()** for lookups so matching is term-based and maintainable.
165+
- **Integration**: `getSolidProfileFromWebId(webIdUrl, accessToken)` returns `{ email?, name? }`; best-effort (returns `{}` on fetch/parse failure). In `verifySolidUser`, we call it when `userinfo.webid` and `tokenset.access_token` exist and merge `profile.email` and `profile.name` into `userinfo`, so the real email is used instead of `...@FAKEDOMAIN.TLD` when the profile contains it, and the name appears in the UI. `getFullName(userinfo)` now considers `userinfo.name` (from profile) before given_name/family_name.
166+
- **UI**: The client sidebar and account popover already display `user.name` and `user.email` from the API; no client changes were required.
167+
160168
## Current Status
161169

162170
### Working Features
@@ -175,6 +183,7 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u
175183
13. **Conversation Delete**: Users can delete conversations and all associated messages from Solid Pod
176184
14. **Conversation Archive**: Users can archive and unarchive conversations stored in Solid Pod
177185
15. **Conversation Share**: Users can share conversations stored in Solid Pod with public read access while maintaining full write permissions
186+
16. **Solid profile email/name**: When the user's WebID profile card (vCard/FOAF) contains email and/or name, these are fetched after login and used for the LibreChat user (sidebar and account popover show real email and name instead of faked email)
178187

179188
### Known Issues 🔧
180189
None currently identified.
@@ -211,6 +220,7 @@ None currently identified.
211220
- **Authorization request**: Solid flow sends `prompt=consent` and `scope=openid webid offline_access` so IdPs that support it can issue a refresh token.
212221
- **JWT strategies**: Solid uses `solidJwt`, generic OpenID uses `openidJwt`; `requireJwtAuth` and `optionalJwtAuth` select the strategy based on the `token_provider` cookie. The cookie is set from `req.user.provider` in `setOpenIDAuthTokens`; the refresh path passes `token_provider` into `performOpenIDRefresh` and sets `req.user`/`user.provider` so the cookie is not overwritten to `openid` after refresh.
213222
- **JWT extraction**: The openIdJwt/solidJwt strategy reads the JWT from `Authorization: Bearer` first, then from the `openid_id_token` cookie, so the first request after redirect can authenticate before the frontend sets the header. We set the `openid_id_token` cookie when storing tokens in session so that fallback works.
223+
- **Solid profile (WebID card)**: When a user logs in with Solid, we optionally fetch their WebID profile document (Turtle) using the access token, parse it with n3 (Store + DataFactory), and extract `vcard:fn`/`foaf:name` for display name and `vcard:hasEmail``vcard:value` (mailto:) for email. This replaces the faked `...@FAKEDOMAIN.TLD` email and improves the displayed name when the profile card contains them. Best-effort; failures do not block login.
214224

215225
### Access Control (ACL)
216226
- Uses manual ACL Turtle format for permission management
@@ -246,7 +256,7 @@ None currently identified.
246256
- `api/server/controllers/AuthController.js` - performOpenIDRefresh accepts tokenProvider param; sets req.user and user.provider before setOpenIDAuthTokens so token_provider cookie preserved on refresh; session fallback when no refresh token; Solid vs openid by token_provider
247257
- `api/server/middleware/requireJwtAuth.js` - Use solidJwt when token_provider is solid, openidJwt when openid; lazy solidJwt registration; sendStrategyNotRegistered503 from openIdAuthHelpers
248258
- `api/server/middleware/optionalJwtAuth.js` - Same strategy selection and 503 helper as requireJwtAuth
249-
- `api/strategies/SolidOpenidStrategy.js` - verifySolidUser return includes provider: 'solid'; prompt=consent for refresh token; SOLID_OPENID_* env; register as 'solid'
259+
- `api/strategies/SolidOpenidStrategy.js` - verifySolidUser return includes provider: 'solid'; prompt=consent for refresh token; SOLID_OPENID_* env; register as 'solid'; **getSolidProfileFromWebId**: fetch WebID profile (Turtle), parse with n3 Store + DataFactory, extract vcard:fn/foaf:name and vcard:hasEmail→value (mailto:); merge into userinfo so real email/name used; getFullName considers userinfo.name
250260
- `api/strategies/openIdJwtStrategy.js` - jwtFromRequest: Authorization Bearer then openid_id_token cookie; used by solidJwt and openidJwt
251261
- `api/server/controllers/auth/solidOpenIdDynamic.js` - startSolidOpenIdFlow, handleSolidOpenIdCallback (multi-issuer with PKCE)
252262
- `api/server/middleware/validate/convoAccess.js` - Solid storage support for conversation access validation

api/strategies/SolidOpenidStrategy.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const passport = require('passport');
55
const client = require('openid-client');
66
const jwtDecode = require('jsonwebtoken/decode');
77
const { HttpsProxyAgent } = require('https-proxy-agent');
8+
const { Parser, Store, DataFactory } = require('n3');
89
const { hashToken, logger } = require('@librechat/data-schemas');
910
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
1011
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
@@ -244,6 +245,121 @@ const downloadImage = async (url, config, accessToken, sub) => {
244245
}
245246
};
246247

248+
const VCARD_NS = 'http://www.w3.org/2006/vcard/ns#';
249+
const FOAF_NS = 'http://xmlns.com/foaf/0.1/';
250+
251+
/**
252+
* Fetches the Solid profile document (WebID card) and extracts email and name from vCard/FOAF.
253+
* Uses n3 for Turtle parsing (no regex). Best-effort: returns {} on any failure.
254+
* @param {string} webIdUrl - The user's WebID (e.g. https://pod.example.com/profile/card#me)
255+
* @param {string} accessToken - Bearer access token from Solid OIDC
256+
* @returns {Promise<{ email?: string, name?: string }>}
257+
*/
258+
async function getSolidProfileFromWebId(webIdUrl, accessToken) {
259+
if (!webIdUrl || typeof webIdUrl !== 'string' || !accessToken) {
260+
return {};
261+
}
262+
const webId = webIdUrl.trim();
263+
let profileDocUrl;
264+
try {
265+
const u = new URL(webId);
266+
u.hash = '';
267+
profileDocUrl = u.href;
268+
} catch {
269+
return {};
270+
}
271+
272+
const fetchOptions = {
273+
method: 'GET',
274+
headers: {
275+
Accept: 'text/turtle',
276+
Authorization: `Bearer ${accessToken}`,
277+
},
278+
};
279+
if (process.env.PROXY) {
280+
fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
281+
}
282+
const controller = new AbortController();
283+
const timeoutId = setTimeout(() => controller.abort(), 5000);
284+
fetchOptions.signal = controller.signal;
285+
286+
let text;
287+
try {
288+
const response = await fetch(profileDocUrl, fetchOptions);
289+
clearTimeout(timeoutId);
290+
if (!response.ok) {
291+
logger.debug(
292+
`[SolidOpenidStrategy] getSolidProfileFromWebId: profile fetch failed ${response.status} ${profileDocUrl}`,
293+
);
294+
return {};
295+
}
296+
text = await response.text();
297+
} catch (err) {
298+
clearTimeout(timeoutId);
299+
logger.debug(
300+
`[SolidOpenidStrategy] getSolidProfileFromWebId: fetch error ${err.message} ${profileDocUrl}`,
301+
);
302+
return {};
303+
}
304+
305+
let quads;
306+
try {
307+
const parser = new Parser({ baseIRI: profileDocUrl });
308+
quads = parser.parse(text);
309+
} catch (err) {
310+
logger.debug(
311+
`[SolidOpenidStrategy] getSolidProfileFromWebId: Turtle parse error ${err.message}`,
312+
);
313+
return {};
314+
}
315+
316+
const { namedNode } = DataFactory;
317+
const store = new Store(quads);
318+
const subjectIri = webId;
319+
const subjectTerm = namedNode(subjectIri);
320+
const hasEmailPred = VCARD_NS + 'hasEmail';
321+
const valuePred = VCARD_NS + 'value';
322+
const fnPred = VCARD_NS + 'fn';
323+
const foafNamePred = FOAF_NS + 'name';
324+
325+
let name;
326+
const fnQuads = store.getQuads(subjectTerm, namedNode(fnPred), null, null);
327+
if (fnQuads.length > 0 && fnQuads[0].object.termType === 'Literal' && fnQuads[0].object.value) {
328+
name = fnQuads[0].object.value;
329+
} else {
330+
const foafNameQuads = store.getQuads(subjectTerm, namedNode(foafNamePred), null, null);
331+
if (
332+
foafNameQuads.length > 0 &&
333+
foafNameQuads[0].object.termType === 'Literal' &&
334+
foafNameQuads[0].object.value
335+
) {
336+
name = foafNameQuads[0].object.value;
337+
}
338+
}
339+
340+
let email;
341+
const hasEmailQuads = store.getQuads(subjectTerm, namedNode(hasEmailPred), null, null);
342+
for (const q of hasEmailQuads) {
343+
const emailNode = q.object;
344+
const valueQuads = store.getQuads(emailNode, namedNode(valuePred), null, null);
345+
for (const vq of valueQuads) {
346+
if (
347+
vq.object.termType === 'NamedNode' &&
348+
vq.object.value.startsWith('mailto:')
349+
) {
350+
email = vq.object.value.slice(7).trim();
351+
break;
352+
}
353+
}
354+
if (email) break;
355+
}
356+
357+
const out = {};
358+
if (email) out.email = email;
359+
if (name) out.name = name;
360+
return out;
361+
}
362+
247363
/**
248364
* Determines the full name of a user based on OpenID userinfo and environment configuration.
249365
*
@@ -252,13 +368,18 @@ const downloadImage = async (url, config, accessToken, sub) => {
252368
* @param {string} [userinfo.family_name] - The user's last name
253369
* @param {string} [userinfo.username] - The user's username
254370
* @param {string} [userinfo.email] - The user's email address
371+
* @param {string} [userinfo.name] - Full name (e.g. from Solid profile vcard:fn)
255372
* @returns {string} The determined full name of the user
256373
*/
257374
function getFullName(userinfo) {
258375
if (process.env.OPENID_NAME_CLAIM) {
259376
return userinfo[process.env.OPENID_NAME_CLAIM];
260377
}
261378

379+
if (userinfo.name) {
380+
return userinfo.name;
381+
}
382+
262383
if (userinfo.given_name && userinfo.family_name) {
263384
return `${userinfo.given_name} ${userinfo.family_name}`;
264385
}
@@ -315,6 +436,12 @@ async function verifySolidUser(tokenset, openidConfig) {
315436
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
316437
};
317438

439+
if (userinfo.webid && tokenset.access_token) {
440+
const profile = await getSolidProfileFromWebId(userinfo.webid, tokenset.access_token);
441+
if (profile.email) userinfo.email = profile.email;
442+
if (profile.name) userinfo.name = profile.name;
443+
}
444+
318445
const appConfig = await getAppConfig();
319446
const email =
320447
userinfo.email ||

0 commit comments

Comments
 (0)