203 lines
6.4 KiB
TypeScript
203 lines
6.4 KiB
TypeScript
import { betterAuth } from 'better-auth'
|
|
import { admin, magicLink } from 'better-auth/plugins'
|
|
import { actor, fabric } from '@pikku/better-auth'
|
|
import { pikkuBetterAuth } from '#pikku/pikku-types.gen.js'
|
|
import { UserRoles, UserTypes } from '@heygermany/sdk'
|
|
import type { EmailTemplateName } from '../../.pikku/email/pikku-emails.gen.js'
|
|
|
|
const DEV_BETTER_AUTH_SECRET = 'dev-better-auth-secret-change-me'
|
|
|
|
const isLocalDomain = (domain: string) => {
|
|
return !domain || domain.includes('localhost') || domain.includes('127.0.0.1')
|
|
}
|
|
|
|
const getOrigin = (domain: string) => {
|
|
if (isLocalDomain(domain)) {
|
|
return 'http://localhost:3000'
|
|
}
|
|
return `https://api.${domain}`
|
|
}
|
|
|
|
export const auth = pikkuBetterAuth(async ({ kysely, secrets, variables, config, emailService }) => {
|
|
const betterAuthSecret =
|
|
(await variables.get('BETTER_AUTH_SECRET')) ||
|
|
await secrets.getSecret('BETTER_AUTH_SECRET').catch(() => DEV_BETTER_AUTH_SECRET)
|
|
|
|
if (process.env.NODE_ENV === 'production' && betterAuthSecret === DEV_BETTER_AUTH_SECRET) {
|
|
throw new Error('BETTER_AUTH_SECRET must be configured in production')
|
|
}
|
|
|
|
// Genuinely optional: unset simply disables /api/auth/sign-in/actor (scenarios
|
|
// off for this deployment) — the actor plugin refuses all sign-ins without it.
|
|
const SCENARIO_ACTOR_SECRET = await secrets
|
|
.getSecret('SCENARIO_ACTOR_SECRET')
|
|
.catch(() => undefined)
|
|
// Fabric operator admin: the RSA public key the control plane's token is
|
|
// verified against. The Fabric deployer pushes FABRIC_AUTH_PUBLIC_KEY onto
|
|
// every stage; locally it's simply absent, which disables /sign-in/fabric.
|
|
const FABRIC_AUTH_PUBLIC_KEY = await variables.get('FABRIC_AUTH_PUBLIC_KEY')
|
|
|
|
return betterAuth({
|
|
secret: betterAuthSecret,
|
|
baseURL: getOrigin(config.domain),
|
|
trustedOrigins: [
|
|
'http://localhost:3001',
|
|
'http://localhost:3000',
|
|
'http://127.0.0.1:3001',
|
|
'http://127.0.0.1:3000',
|
|
`https://${config.domain}`,
|
|
`https://api.${config.domain}`,
|
|
// Fabric deploys every runtime to a *.pikkufabric.dev subdomain; the
|
|
// request Origin is that dynamic host, so trust it or auth 403s
|
|
// INVALID_ORIGIN (baseURL is pinned to the real api.<domain>).
|
|
'https://*.pikkufabric.dev',
|
|
],
|
|
advanced: {
|
|
database: {
|
|
generateId: 'uuid',
|
|
},
|
|
crossSubDomainCookies: {
|
|
enabled: !isLocalDomain(config.domain),
|
|
domain: isLocalDomain(config.domain) ? undefined : config.domain,
|
|
},
|
|
},
|
|
database: {
|
|
db: kysely as any,
|
|
type: 'sqlite',
|
|
},
|
|
user: {
|
|
modelName: 'authUser',
|
|
fields: {
|
|
emailVerified: 'emailVerified',
|
|
createdAt: 'createdAt',
|
|
updatedAt: 'updatedAt',
|
|
},
|
|
additionalFields: {
|
|
role: {
|
|
type: 'string',
|
|
required: true,
|
|
defaultValue: UserRoles.OWNER,
|
|
input: true,
|
|
},
|
|
type: {
|
|
type: 'string',
|
|
required: true,
|
|
defaultValue: UserTypes.CANDIDATE,
|
|
input: true,
|
|
},
|
|
},
|
|
},
|
|
session: {
|
|
modelName: 'authSession',
|
|
// Signed session cookie cache: lets the global middleware resolve the
|
|
// session statelessly (betterAuthStatelessSession) without services.auth()
|
|
// — which is tree-shaken off the RPC bundle on the deployed CF Worker, so
|
|
// the full-server betterAuthSession 500s there.
|
|
cookieCache: { enabled: true, maxAge: 5 * 60 },
|
|
fields: {
|
|
expiresAt: 'expiresAt',
|
|
createdAt: 'createdAt',
|
|
updatedAt: 'updatedAt',
|
|
ipAddress: 'ipAddress',
|
|
userAgent: 'userAgent',
|
|
userId: 'userId',
|
|
},
|
|
},
|
|
account: {
|
|
modelName: 'authAccount',
|
|
fields: {
|
|
accountId: 'accountId',
|
|
providerId: 'providerId',
|
|
userId: 'userId',
|
|
accessToken: 'accessToken',
|
|
refreshToken: 'refreshToken',
|
|
idToken: 'idToken',
|
|
accessTokenExpiresAt: 'accessTokenExpiresAt',
|
|
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
|
|
createdAt: 'createdAt',
|
|
updatedAt: 'updatedAt',
|
|
},
|
|
},
|
|
verification: {
|
|
modelName: 'authVerification',
|
|
fields: {
|
|
expiresAt: 'expiresAt',
|
|
createdAt: 'createdAt',
|
|
updatedAt: 'updatedAt',
|
|
},
|
|
},
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
emailVerification: {
|
|
sendVerificationEmail: async ({ user, url }) => {
|
|
await emailService?.send({
|
|
to: user.email,
|
|
template: {
|
|
name: 'candidate-double-opt-in',
|
|
locale: 'en',
|
|
data: {
|
|
firstname: user.name,
|
|
verificationLink: url,
|
|
},
|
|
},
|
|
})
|
|
},
|
|
},
|
|
// admin(): list/setRole/ban/impersonate users (db/sqlite/0005-admin.sql).
|
|
// actor(): synthetic scenario users (db/sqlite/0004-user-actor.sql).
|
|
// fabric(): Fabric console operator sign-in (db/sqlite/0006-fabric.sql).
|
|
plugins: [
|
|
actor({ secret: SCENARIO_ACTOR_SECRET }),
|
|
admin(),
|
|
fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY }),
|
|
magicLink({
|
|
disableSignUp: true,
|
|
sendMagicLink: async ({ email: to, token, metadata }) => {
|
|
const user = await kysely
|
|
.selectFrom('authUser')
|
|
.select(['id', 'name'])
|
|
.where('email', '=', to)
|
|
.executeTakeFirst()
|
|
|
|
const name = user?.name || to.split('@')[0]
|
|
const templateName = getMagicLinkTemplateName(metadata?.template)
|
|
const magicLink = buildMagicLink(config.domain, token)
|
|
|
|
await emailService?.send({
|
|
to,
|
|
template: {
|
|
name: templateName,
|
|
locale: 'en',
|
|
data: {
|
|
firstname: name,
|
|
magicLink,
|
|
},
|
|
},
|
|
})
|
|
},
|
|
}),
|
|
],
|
|
})
|
|
})
|
|
|
|
const buildMagicLink = (domain: string, token: string) => {
|
|
const host = isLocalDomain(domain) ? `http://${domain}` : `https://${domain}`
|
|
return `${host}/auth/magic-link?token=${token}&redirect=/jobs/application`
|
|
}
|
|
|
|
const MAGIC_LINK_TEMPLATE_NAMES = new Set<EmailTemplateName>([
|
|
'candidate-magic-link',
|
|
'candidate-qualified',
|
|
'candidate-invalid-documents',
|
|
'candidate-process-incomplete',
|
|
])
|
|
|
|
const getMagicLinkTemplateName = (template: unknown): EmailTemplateName => {
|
|
if (typeof template === 'string' && MAGIC_LINK_TEMPLATE_NAMES.has(template as EmailTemplateName)) {
|
|
return template as EmailTemplateName
|
|
}
|
|
|
|
return 'candidate-magic-link'
|
|
}
|