From 831b1d5127a2b3331db25e018a8cb71c54a86f09 Mon Sep 17 00:00:00 2001 From: Enes Uysal Date: Wed, 12 Aug 2026 15:45:13 +0300 Subject: [PATCH] feat(profile): optionally download the profile picture with the access token Some providers serve the picture claim from an endpoint that requires the access token, so storing the URL leaves users with an avatar that resolves to 401. Microsoft Graph's /me/photo/$value is one such case. --- lib/controllers.js | 2 +- library.js | 44 +++++++++++++++++++ .../partials/edit-oauth2-strategy.tpl | 12 +++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/controllers.js b/lib/controllers.js index a9c81fc..340c610 100644 --- a/lib/controllers.js +++ b/lib/controllers.js @@ -74,7 +74,7 @@ Controllers.editStrategy = async (req, res) => { payload.enabled = !!req.body.enabled; - const checkboxes = ['forceUsernameViaEmail', 'usernameViaEmail', 'trustEmailVerified', 'syncFullname', 'syncPicture']; + const checkboxes = ['forceUsernameViaEmail', 'usernameViaEmail', 'trustEmailVerified', 'syncFullname', 'syncPicture', 'fetchPictureWithToken']; checkboxes.forEach((prop) => { payload[prop] = payload.hasOwnProperty(prop) && payload[prop] === 'on' ? 1 : 0; }); diff --git a/library.js b/library.js index 961bb15..414ef6f 100644 --- a/library.js +++ b/library.js @@ -154,6 +154,7 @@ OAuth.getUserProfile = function (name, userRoute, accessToken, done) { const json = JSON.parse(body); const profile = await OAuth.parseUserReturn(name, json); profile.provider = name; + profile.accessToken = accessToken; done(null, profile); } catch (e) { done(e); @@ -277,11 +278,54 @@ OAuth.assignGroups = async ({ user, profile }) => { winston.verbose(`[plugins/sso-auth0] uid ${uid} now a part of ${toJoin.length} these user groups: ${toJoin.join(', ')}`); }; +OAuth.syncPictureViaToken = async (uid, profile, strategy) => { + const enabled = parseInt(strategy.syncPicture, 10) && parseInt(strategy.fetchPictureWithToken, 10); + if (!enabled || !profile.picture || !profile.accessToken) { + return false; + } + + const { uploadedpicture } = await user.getUserFields(uid, ['uploadedpicture']); + if (uploadedpicture) { + return true; + } + + try { + const res = await fetch(profile.picture, { + headers: { Authorization: `Bearer ${profile.accessToken}` }, + }); + if (!res.ok) { + winston.verbose(`[plugin/sso-oauth2-multiple] Picture for uid ${uid} unavailable (${res.status})`); + return false; + } + + const buffer = Buffer.from(await res.arrayBuffer()); + if (!buffer.length) { + return false; + } + + const type = (res.headers.get('content-type') || 'image/jpeg').split(';')[0]; + await user.uploadCroppedPicture({ + callerUid: uid, + uid, + imageData: `data:${type};base64,${buffer.toString('base64')}`, + }); + + return true; + } catch (err) { + winston.warn(`[plugin/sso-oauth2-multiple] Unable to sync picture for uid ${uid}: ${err.message}`); + return false; + } +}; + OAuth.updateProfile = async (uid, profile) => { const fields = ['fullname', 'picture']; const strategy = await OAuth.getStrategy(profile.provider); const allowList = []; + if (await OAuth.syncPictureViaToken(uid, profile, strategy)) { + fields.splice(fields.indexOf('picture'), 1); + } + const payload = fields.reduce((memo, field) => { const setting = `sync${field[0].toUpperCase()}${field.slice(1)}`; if (strategy[setting] && parseInt(strategy[setting], 10)) { diff --git a/static/templates/partials/edit-oauth2-strategy.tpl b/static/templates/partials/edit-oauth2-strategy.tpl index dcc4f65..210b964 100644 --- a/static/templates/partials/edit-oauth2-strategy.tpl +++ b/static/templates/partials/edit-oauth2-strategy.tpl @@ -142,6 +142,18 @@ + +
+ + +