- Set Spanish as default language with ephemeral/encrypted privacy focus - Translate all user-facing strings and legal pages to Spanish - Replace Norwegian flag with Spanish flag in footer - Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship - Rewrite PrivacyPage: zero data collection, ephemeral design emphasis - Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references - Update PWA manifest, HTML meta tags, package.json branding - Rename webhook headers to X-Paste-Event / X-Paste-Signature - Update API docs title and contact to paste.es / cloudhost.es Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
177 lines
6.2 KiB
TypeScript
177 lines
6.2 KiB
TypeScript
import { betterAuth } from 'better-auth';
|
|
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
|
import { APIError } from 'better-auth/api';
|
|
import { admin, twoFactor, username } from 'better-auth/plugins';
|
|
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
|
import { randomBytes } from 'crypto';
|
|
import config, { type SocialProviderConfig } from './config';
|
|
import prisma from './lib/db';
|
|
import { validatePassword } from './validations/password';
|
|
|
|
// Generate a unique username from email
|
|
const generateUsernameFromEmail = (email: string): string => {
|
|
const localPart = email.split('@')[0] || 'user';
|
|
// Sanitize: only keep alphanumeric characters and underscores
|
|
const sanitized = localPart.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
|
|
// Add random suffix to ensure uniqueness (cryptographically secure)
|
|
const randomSuffix = randomBytes(4).toString('hex').substring(0, 6);
|
|
return `${sanitized}_${randomSuffix}`;
|
|
};
|
|
|
|
// Build better-auth social providers configuration dynamically
|
|
const buildBetterAuthSocialProviders = () => {
|
|
const providers = config.getSocialProviders();
|
|
const betterAuthProviders: Record<
|
|
string,
|
|
{
|
|
clientId: string;
|
|
clientSecret: string;
|
|
tenantId?: string;
|
|
issuer?: string;
|
|
mapProfileToUser?: (profile: { email?: string; name?: string }) => { username: string };
|
|
}
|
|
> = {};
|
|
|
|
for (const [provider, providerConfig] of Object.entries(providers)) {
|
|
const typedConfig = providerConfig as SocialProviderConfig;
|
|
betterAuthProviders[provider] = {
|
|
clientId: typedConfig.clientId,
|
|
clientSecret: typedConfig.clientSecret,
|
|
...(typedConfig.tenantId && { tenantId: typedConfig.tenantId }),
|
|
...(typedConfig.issuer && { issuer: typedConfig.issuer }),
|
|
mapProfileToUser: (profile) => ({
|
|
username: generateUsernameFromEmail(profile.email || profile.name || 'user'),
|
|
}),
|
|
};
|
|
}
|
|
|
|
return betterAuthProviders;
|
|
};
|
|
|
|
// Build better-auth plugins array
|
|
const buildPlugins = () => {
|
|
const plugins: any[] = [username(), admin(), twoFactor()];
|
|
|
|
const genericProviders = config.getGenericOAuthProviders();
|
|
if (genericProviders.length > 0) {
|
|
plugins.push(
|
|
genericOAuth({
|
|
config: genericProviders.map((provider) => ({
|
|
...provider,
|
|
// Map profile to include username
|
|
mapProfileToUser: (profile: any) => ({
|
|
username: generateUsernameFromEmail(
|
|
profile.email || profile.name || 'user'
|
|
),
|
|
}),
|
|
})),
|
|
})
|
|
);
|
|
}
|
|
|
|
return plugins;
|
|
};
|
|
|
|
export const auth = betterAuth({
|
|
appName: 'Hemmelig',
|
|
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
|
|
database: prismaAdapter(prisma, {
|
|
provider: 'sqlite',
|
|
}),
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
// Set to 1 so better-auth doesn't reject weak current passwords during password change.
|
|
// Password strength for new passwords is enforced by our Zod schema (updatePasswordSchema)
|
|
// and for sign-up by the before hook below.
|
|
minPasswordLength: 1,
|
|
},
|
|
socialProviders: buildBetterAuthSocialProviders(),
|
|
account: {
|
|
accountLinking: {
|
|
enabled: true,
|
|
trustedProviders: [
|
|
'gitlab',
|
|
'github',
|
|
'google',
|
|
'microsoft',
|
|
'discord',
|
|
'apple',
|
|
'twitter',
|
|
// Add all generic OAuth provider IDs as trusted
|
|
...config.getGenericOAuthProviders().map((p) => p.providerId),
|
|
],
|
|
},
|
|
},
|
|
plugins: buildPlugins(),
|
|
trustedOrigins: config.get('trustedOrigins'),
|
|
hooks: {
|
|
before: async (context) => {
|
|
// Only apply validation to email/password sign-up
|
|
if (context.path !== '/sign-up/email') {
|
|
return;
|
|
}
|
|
|
|
const body = context.body as { email?: string; password?: string };
|
|
const email = body?.email;
|
|
const password = body?.password;
|
|
|
|
if (!email) {
|
|
return;
|
|
}
|
|
|
|
// Validate password strength for sign-up
|
|
if (password) {
|
|
const passwordError = validatePassword(password);
|
|
if (passwordError) {
|
|
throw new APIError('BAD_REQUEST', { message: passwordError });
|
|
}
|
|
}
|
|
|
|
// Get instance settings
|
|
const settings = await prisma.instanceSettings.findFirst({
|
|
select: { allowedEmailDomains: true, disableEmailPasswordSignup: true },
|
|
});
|
|
|
|
// Check if email/password signup is disabled
|
|
if (settings?.disableEmailPasswordSignup) {
|
|
throw new APIError('FORBIDDEN', {
|
|
message: 'Email/password registration is disabled. Please use social login.',
|
|
});
|
|
}
|
|
|
|
const allowedDomains = settings?.allowedEmailDomains?.trim();
|
|
|
|
// If no domains configured, allow all
|
|
if (!allowedDomains) {
|
|
return;
|
|
}
|
|
|
|
// Parse comma-separated domains
|
|
const domains = allowedDomains
|
|
.split(',')
|
|
.map((d) => d.trim().toLowerCase())
|
|
.filter((d) => d.length > 0);
|
|
|
|
if (domains.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Extract domain from email
|
|
const emailDomain = email.split('@')[1]?.toLowerCase();
|
|
|
|
if (!emailDomain || !domains.includes(emailDomain)) {
|
|
throw new APIError('FORBIDDEN', {
|
|
message: 'Email domain not allowed',
|
|
});
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
// Export enabled social providers for frontend consumption
|
|
export const getEnabledSocialProviders = (): string[] => {
|
|
const standardProviders = Object.keys(config.getSocialProviders());
|
|
const genericProviders = config.getGenericOAuthProviders().map((p) => p.providerId);
|
|
return [...standardProviders, ...genericProviders];
|
|
};
|