Possibility to configure an OpenID Connect provider on the instance level WIP (#128)

Get avatar from remote service.
This commit is contained in:
John Livingston 2024-04-18 18:25:14 +02:00
parent 2334a5f861
commit 8a65f447c8
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
6 changed files with 121 additions and 15 deletions

View File

@ -1,11 +1,14 @@
local json = require "util.json";
local jid_split = require"util.jid".split;
local usermanager = require "core.usermanager";
local st = require "util.stanza"
module:depends"http";
local module_host = module:get_host(); -- this module is not global
local vcards = module:open_store("vcard");
function check_auth(routes)
local function check_request_auth(event)
local apikey = module:get_option_string("peertubelivechat_manage_users_apikey", "")
@ -31,6 +34,26 @@ function check_auth(routes)
end
local function update_vcard(username, avatar)
if not avatar then
module:log("debug", "No avatar for user %s, deleting vcard", username);
vcards:set(username, nil)
return
end
module:log("debug", "There is a avatar for user %s, storing the relevant vcard.", username);
local vcard_temp = st.stanza("vCard", { xmlns = "vcard-temp" });
vcard_temp:tag("PHOTO");
vcard_temp:text_tag("TYPE", avatar.mimetype);
-- avatar.base64 is already base64 encoded
vcard_temp:text_tag("BINVAL", avatar.base64);
vcard_temp:up();
if not vcards:set(username, st.preserialize(vcard_temp)) then
module:log("error", "Failed to store the vcard for user %s", username);
end
end
local function ensure_user(event)
local request, response = event.request, event.response;
event.response.headers["Content-Type"] = "application/json";
@ -42,29 +65,30 @@ local function ensure_user(event)
});
end
module:log("debug", "Calling ensure_user", config.jid);
module:log("debug", "Calling ensure_user for %s", config.jid);
local username, host = jid_split(config.jid);
if module_host ~= host then
module:log("error", "Wrong host", host);
module:log("error", "Wrong host %s", host);
return json.encode({
result = "failed";
message = "Wrong host"
});
end
-- TODO: handle avatars.
-- if user exists, just update.
if usermanager.user_exists(username, host) then
module:log("debug", "User already exists, updating", filename);
module:log("debug", "User already exists, updating");
if not usermanager.set_password(username, config.password, host, nil) then
module:log("error", "Failed to update the password", host);
module:log("error", "Failed to update the password");
return json.encode({
result = "failed";
message = "Failed to update the password"
});
end
update_vcard(username, config.avatar);
return json.encode({
result = "ok";
message = "User updated"
@ -72,14 +96,17 @@ local function ensure_user(event)
end
-- we must create the user.
module:log("debug", "User does not exists, creating", filename);
module:log("debug", "User does not exists, creating");
if (not usermanager.create_user(username, config.password, host)) then
module:log("error", "Failed to create the user", host);
module:log("error", "Failed to create the user");
return json.encode({
result = "failed";
message = "Failed to create the user"
});
end
update_vcard(username, config.avatar);
return json.encode({
result = "ok";
message = "User created"

View File

@ -1,6 +1,6 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Request, Response, CookieOptions } from 'express'
import type { ExternalAccountInfos } from './types'
import type { ExternalAccountInfos, AcceptableAvatarMimeType } from './types'
import { ExternalAuthenticationError } from './error'
import { getBaseRouterRoute } from '../helpers'
import { canonicalizePluginUri } from '../uri/canonicalize'
@ -10,7 +10,38 @@ import { Issuer, BaseClient, generators, UnknownObject } from 'openid-client'
import { JID } from '@xmpp/jid'
import { URL } from 'url'
type UserInfoField = 'username' | 'last_name' | 'first_name' | 'nickname'
const got = require('got')
function getMimeTypeFromArrayBuffer (arrayBuffer: ArrayBuffer): AcceptableAvatarMimeType | null {
const uint8arr = new Uint8Array(arrayBuffer)
const len = 4
if (uint8arr.length >= len) {
const signatureArr = new Array(len)
for (let i = 0; i < len; i++) {
signatureArr[i] = (new Uint8Array(arrayBuffer))[i].toString(16)
}
const signature = signatureArr.join('').toUpperCase()
switch (signature) {
case '89504E47':
return 'image/png'
case '47494638':
return 'image/gif'
case 'FFD8FFDB':
case 'FFD8FFE0':
return 'image/jpeg'
case '52494646':
case '57454250':
return 'image/webp'
default:
return null
}
}
return null
}
type UserInfoField = 'username' | 'last_name' | 'first_name' | 'nickname' | 'picture'
interface UnserializedToken {
jid: string
@ -354,11 +385,14 @@ class ExternalAuthOIDC {
}
const token = await this.encrypt(JSON.stringify(tokenContent))
const avatar = await this.readUserInfoPicture(userInfo)
return {
jid,
nickname,
password,
token
token,
avatar
}
}
@ -458,6 +492,7 @@ class ExternalAuthOIDC {
guesses.push('given_name')
break
case 'nickname':
guesses.push('preferred_username')
guesses.push('name')
break
}
@ -471,6 +506,39 @@ class ExternalAuthOIDC {
return undefined
}
/**
* Read and get the avatar for the remote user (if exists).
* @param userInfos userInfos returned by the remote OIDC Provider.
*/
private async readUserInfoPicture (userInfos: UnknownObject): Promise<undefined | ExternalAccountInfos['avatar']> {
// According to "Standard Claims" section https://openid.net/specs/openid-connect-core-1_0.html,
// there should be a `picture` field containing an URL to the avatar
const picture = this.readUserInfoField(userInfos, 'picture')
if (!picture) { return undefined }
try {
const url = new URL(picture)
const buf = await got(url.toString(), {
method: 'GET',
headers: {},
responseType: 'buffer'
}).buffer()
const mimeType = await getMimeTypeFromArrayBuffer(buf)
if (!mimeType) {
throw new Error('Failed to get the avatar file type')
}
return {
mimetype: mimeType,
base64: buf.toString('base64')
}
} catch (err) {
this.logger.error(`Failed to get the user avatar: ${err as string}`)
return undefined
}
}
/**
* Compute the JID to use for this remote account.
* Format will be: "username+remote.domain.tld@external.instance.tld"

View File

@ -1,11 +1,17 @@
type AcceptableAvatarMimeType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'
interface ExternalAccountInfos {
nickname: string
jid: string
password: string
token: string
// TODO: avatar
avatar?: {
mimetype: AcceptableAvatarMimeType
base64: string
}
}
export {
ExternalAccountInfos
ExternalAccountInfos,
AcceptableAvatarMimeType
}

View File

@ -34,7 +34,8 @@ async function ensureUser (options: RegisterServerOptions, infos: ExternalAccoun
const apiData = {
jid: infos.jid,
nickname: infos.nickname,
password: infos.password
password: infos.password,
avatar: infos.avatar
}
try {
logger.debug('Calling ensure-user API on url: ' + apiUrl)

View File

@ -231,6 +231,7 @@ class ProsodyConfigContent {
this.external.set('modules_enabled', [
'ping',
'http',
'vcard',
'http_peertubelivechat_manage_users'
])
this.external.set('peertubelivechat_manage_users_apikey', apikey)

View File

@ -72,7 +72,10 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise<Router>
externalAccountInfos,
{
password: '**removed**', // removing the password from logs!
token: '**removed**' // same as password
token: '**removed**', // same as password
avatar: externalAccountInfos.avatar
? `**removed** ${externalAccountInfos.avatar.mimetype} avatar`
: undefined
}
)
))