/* eslint-disable @stylistic/max-len */ import fs from 'node:fs/promises'; import type { GrantConfig, GrantResponse } from 'grant'; import { importPKCS8, SignJWT } from 'jose'; import { rootDir } from './paths.ts'; import { getUrlForLocale } from '~/src/domain.ts'; const getAppleClientSecret = async (): Promise => { 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`; let privateKeyContent; try { privateKeyContent = await fs.readFile(applePrivateKeyFile, 'utf-8'); } catch (error) { return undefined; } const privateKey = await importPKCS8(privateKeyContent, 'ES256'); return await new SignJWT(claims) .setProtectedHeader(headers) .setExpirationTime('180d') .sign(privateKey); }; 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: getUrlForLocale('_'), 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: await 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 | null; 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 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, email: null, name: acct, avatar: r.profile.avatar, access_token: r.access_token, instance, }; }, indieauth(r: GrantResponse): SocialProfile { return { id: r.profile.me, email: null, 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 = { discord: ['username', 'id'], facebook: ['id'], google: ['id'], indieauth: ['instance'], mastodon: ['name', 'id'], twitter: ['name'], };