PronounsPage/server/social.ts
2024-09-12 10:11:25 +02:00

203 lines
6.3 KiB
TypeScript

import fs from 'fs';
import jwt from 'jsonwebtoken';
import type { GrantConfig, GrantResponse } from 'grant';
import { rootDir } from './paths.ts';
const getAppleClientSecret = (): string => {
const headers = {
alg: 'ES256',
kid: process.env.APPLE_KEY_ID,
typ: undefined,
};
const claims = {
iss: process.env.APPLE_TEAM_ID,
aud: 'https://appleid.apple.com',
sub: process.env.APPLE_CLIENT_ID,
};
const applePrivateKeyFile = `${rootDir}/keys/AuthKey_${process.env.APPLE_KEY_ID}.p8`;
const privateKey = fs.existsSync(applePrivateKeyFile) ? fs.readFileSync(applePrivateKeyFile).toString('utf-8') : '';
return jwt.sign(claims, privateKey, {
algorithm: 'ES256',
header: headers,
expiresIn: '180d',
});
};
function appleIsEnabled(): boolean {
const conditions = [
'APPLE_CLIENT_ID',
'APPLE_TEAM_ID',
'APPLE_KEY_ID',
'APPLE_PRIVATE_KEY',
];
const unavailable = [];
for (const condition of conditions) {
const value = process.env[condition];
// Checks if the value is not a string or empty
if (typeof value !== 'string' || value.length < 1 || value.trim().length < 1) {
unavailable.push(condition);
}
}
if (unavailable.length === 0) {
return true;
}
if (unavailable.length === conditions.length) {
console.log('Apple authentication is disabled - provide the APPLE_CLIENT_ID, APPLE_TEAM_ID, and APPLE_PRIVATE_KEY to enable these');
} else {
console.warn(`Apple authentication is disabled because all required environment values were not provided (missing: ${unavailable.join(', ')})`);
}
return false;
}
const enableApple = appleIsEnabled();
export const config: GrantConfig = {
defaults: {
origin: process.env.BASE_URL,
transport: 'session',
state: true,
prefix: '/api/connect',
scope: ['email'],
response: ['tokens', 'raw', 'profile'],
},
twitter: {
key: process.env.TWITTER_KEY,
secret: process.env.TWITTER_SECRET,
callback: '/api/user/social/twitter',
profile_url: 'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true',
},
facebook: {
key: process.env.FACEBOOK_KEY,
secret: process.env.FACEBOOK_SECRET,
callback: '/api/user/social/facebook',
profile_url: 'https://graph.facebook.com/me?fields=email,name,picture',
},
google: {
key: process.env.GOOGLE_KEY,
secret: process.env.GOOGLE_SECRET,
callback: '/api/user/social/google',
},
discord: {
key: process.env.DISCORD_KEY,
secret: process.env.DISCORD_SECRET,
callback: '/api/user/social/discord',
scope: ['identify', 'email'],
},
// non-grant, but things break if it's not there
mastodon: {},
indieauth: {},
};
if (enableApple) {
config.apple = {
key: process.env.APPLE_CLIENT_ID,
secret: getAppleClientSecret(),
callback: '/api/user/social/apple',
scope: ['openid', 'name', 'email'],
response: ['raw', 'jwt'],
nonce: true,
custom_params: {
response_type: 'code id_token',
response_mode: 'form_post',
},
};
}
interface SocialProfile {
id: string;
email: string;
name: string;
username?: string;
avatar?: string;
access_token?: string | undefined;
access_secret?: string | undefined;
instance?: string;
}
export interface SocialProfilePayload extends SocialProfile {
avatarCopy?: string | null;
}
export const handlers: Record<string, (r: GrantResponse) => SocialProfile> = {
twitter(r: GrantResponse): SocialProfile {
return {
id: r.profile.id_str,
email: r.profile.email,
name: r.profile.screen_name,
avatar: r.profile.profile_image_url_https.replace('_normal', '_400x400'),
access_token: r.access_token,
access_secret: r.access_secret,
};
},
facebook(r: GrantResponse): SocialProfile {
return {
id: r.profile.id,
email: r.profile.email,
name: r.profile.name,
avatar: r.profile.picture.data.url,
access_token: r.access_token,
access_secret: r.access_secret,
};
},
google(r: GrantResponse): SocialProfile {
return {
id: r.profile.sub,
email: r.profile.email_verified !== false ? r.profile.email : undefined,
name: r.profile.email,
avatar: r.profile.picture,
access_token: r.access_token,
access_secret: r.access_secret,
};
},
discord(r: GrantResponse): SocialProfile {
return {
id: r.profile.id,
email: r.profile.email,
name: r.profile.username,
username: `${r.profile.username}#${r.profile.discriminator}`,
avatar: `https://cdn.discordapp.com/avatars/${r.profile.id}/${r.profile.avatar}`,
access_token: r.access_token,
access_secret: r.access_secret,
};
},
mastodon(r: GrantResponse): SocialProfile {
const instance = (r as { instance: string }).instance;
const acct = `${r.profile.username}@${instance}`;
return {
id: acct,
// very possibly not really operated by the user
email: acct,
name: acct,
avatar: r.profile.avatar,
access_token: r.access_token,
instance,
};
},
indieauth(r: GrantResponse): SocialProfile {
return {
id: r.profile.me,
email: `indieauth@${r.profile.domain}`,
name: r.profile.domain,
instance: (r as { instance: string }).instance,
};
},
apple(r: GrantResponse): SocialProfile {
const payload = r.jwt!.id_token!.payload;
return {
id: payload.email,
email: payload.email_verified ? payload.email : null,
name: payload.email,
};
},
};
export const lookup: Record<string, ('id' | 'username' | 'name' | 'instance')[]> = {
discord: ['username', 'id'],
facebook: ['id'],
google: ['id'],
indieauth: ['instance'],
mastodon: ['name', 'id'],
twitter: ['name'],
};