Skip to content

ladder-auth-service

auth-service es la única fuente de verdad de identidad para todos los proyectos (LADDER Dev, CNM Cigars, Plataforma Pintores, y las APIs nuevas que vengan). Posee la DB de auth en Postgres. No conoce nada de la data de negocio de cada proyecto (eso vive en cada API por separado).

2. Dominio público, independiente del gateway

Section titled “2. Dominio público, independiente del gateway”
Cloudflare
├── auth.ladderdev.com → auth-service (público, independiente)
└── api.ladderdev.com → gateway (público, independiente)

No va detrás del gateway. Razones:

  • El gateway necesita consultarle el JWKS para verificar JWTs — si estuviera detrás del gateway habría una dependencia circular (el gateway dependería de algo que él mismo tendría que enrutar primero).
  • Login/signup no tienen JWT todavía — es donde se generan. No tiene sentido pasar por una capa cuyo trabajo es “verificar JWT” para llegar al servicio que los crea.
  • Es la capa de más bajo nivel de la infra. Si el gateway tuviera un bug o se cae, nadie debería perder la capacidad de loguearse — por eso auth-service no depende de él.

El browser habla directo con auth-service para todo lo relacionado a sesión (login, signup, refresh de JWT, passkey). El browser habla con el gateway para todo lo relacionado a data de negocio de cada proyecto.

  • user: una fila por persona real, email único globalmente (no por proyecto).
  • organization: una fila por proyecto (ladder-api, cnm-cigars, plataforma-pintores, …).
  • member: tabla puente user_id <-> organization_id con rol — evita duplicar usuarios entre proyectos.
  • ssoProvider: proveedores OAuth vinculados a un organizationId — cada proyecto controla si ofrece Google u otro.
  • passkey: credenciales WebAuthn ligadas a user, no a un proyecto — sirve para todos los proyectos del mismo usuario.
  • serviceFlag: kill-switch/feature-flags (route:<org>, module:<org>:<modulo>), consultado por gateway y por las APIs.
user ──< member >── organization ──< ssoProvider
│
├──< passkey
└──< account, session (better-auth core)
import { pgTable, text, boolean, integer, timestamp, index } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name'),
email: text('email').notNull().unique(), // único GLOBAL, no por proyecto
emailVerified: boolean('email_verified').notNull().default(false),
image: text('image'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const session = pgTable('session', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
activeOrganizationId: text('active_organization_id'),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
})
export const account = pgTable('account', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(), // 'credential' | 'google' | ...
accountId: text('account_id').notNull(),
password: text('password'),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
})
export const organization = pgTable('organization', {
id: text('id').primaryKey(), // slug: 'ladder-api', 'cnm-cigars', ...
name: text('name').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
export const member = pgTable('member', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
role: text('role').notNull().default('member'),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => ({
orgUserIdx: index('member_org_user_idx').on(table.organizationId, table.userId),
}))
export const ssoProvider = pgTable('sso_provider', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
clientId: text('client_id').notNull(),
clientSecret: text('client_secret').notNull(),
})
export const passkey = pgTable('passkey', {
id: text('id').primaryKey(),
name: text('name'),
publicKey: text('public_key').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
credentialID: text('credential_id').notNull(),
counter: integer('counter').notNull(),
deviceType: text('device_type').notNull(),
backedUp: boolean('backed_up').notNull(),
transports: text('transports'),
aaguid: text('aaguid'),
createdAt: timestamp('created_at').defaultNow(),
})
export const serviceFlag = pgTable('service_flag', {
key: text('key').primaryKey(), // 'route:cnm-cigars' | 'module:ladder-api:billing'
enabled: boolean('enabled').notNull().default(true),
reason: text('reason'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})

Better Auth genera la mayoría de este schema vía npx @better-auth/cli generate una vez configurados los plugins — úsalo como checklist. El índice compuesto de member y la tabla serviceFlag sí son manuales, añádelos tras generar.

Terminal window
npm install better-auth @better-auth/passkey drizzle-orm pg hono @hono/node-server
import { betterAuth } from 'better-auth'
import { organization, sso, jwt } from 'better-auth/plugins'
import { passkey } from '@better-auth/passkey'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from './db/client'
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: {
enabled: true, // global — cada proyecto decide si lo muestra en su UI
},
plugins: [
organization({
allowUserToCreateOrganization: false, // tú creas los proyectos, no el usuario final
}),
sso(), // Google/OIDC registrable por organizationId
passkey({
rpID: 'ladderdev.com',
rpName: 'LADDER Dev',
origin: 'https://auth.ladderdev.com',
}),
jwt({
jwt: { expirationTime: '10m' }, // sesión stateless — el gateway verifica sin tocar esta DB
}),
],
session: {
additionalFields: {
activeOrganizationId: { type: 'string', required: false },
},
},
})
auth-service/src/index.ts
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { auth } from './auth'
import { flagsRoute } from './routes/flags'
const app = new Hono()
app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw))
app.route('/', flagsRoute)
serve({ fetch: app.fetch, port: 4000 })

Cubre: sign-up/sign-in email+password, OAuth/SSO por org, passkey (registro/login), refresh de sesión/JWT, /api/auth/jwks (consultado por el gateway).

5.2 Flags (/api/flags) — kill switch sin Redis

Section titled “5.2 Flags (/api/flags) — kill switch sin Redis”
auth-service/src/routes/flags.ts
import { Hono } from 'hono'
import { db } from '../db/client'
import { serviceFlag } from '../db/schema'
export const flagsRoute = new Hono()
flagsRoute.get('/api/flags', async (c) => {
const flags = await db.select().from(serviceFlag)
return c.json(Object.fromEntries(flags.map(f => [f.key, f.enabled])))
})
flagsRoute.post('/api/flags', async (c) => {
if (c.req.header('X-Admin-Key') !== process.env.ADMIN_KEY) {
return c.json({ error: 'unauthorized' }, 401)
}
const { key, enabled, reason } = await c.req.json()
await db.insert(serviceFlag)
.values({ key, enabled, reason, updatedAt: new Date() })
.onConflictDoUpdate({ target: serviceFlag.key, set: { enabled, reason, updatedAt: new Date() } })
return c.json({ ok: true })
})

Uso: curl -X POST https://auth.ladderdev.com/api/flags -H 'X-Admin-Key: ...' -d '{"key":"route:cnm-cigars","enabled":false}'

import { createAuthClient } from 'better-auth/client'
import { passkeyClient } from '@better-auth/passkey/client'
export const authClient = createAuthClient({
baseURL: 'https://auth.ladderdev.com',
plugins: [passkeyClient()],
})
await authClient.passkey.addPasskey({ name: 'Mi laptop' })
await authClient.signIn.passkey()

7. Crear un proyecto (organización) + SSO por proyecto

Section titled “7. Crear un proyecto (organización) + SSO por proyecto”
await auth.api.createOrganization({ id: 'ladder-api', name: 'LADDER Dev API' })
await auth.api.createOrganization({ id: 'cnm-cigars', name: 'CNM Cigars' })
await auth.api.createOrganization({ id: 'plataforma-pintores', name: 'Plataforma Pintores' })
await auth.api.registerSSOProvider({
organizationId: 'cnm-cigars',
providerId: 'google',
clientId: process.env.CNM_GOOGLE_CLIENT_ID!,
clientSecret: process.env.CNM_GOOGLE_CLIENT_SECRET!,
})

8. Offboarding — cliente que migra y se lleva su data

Section titled “8. Offboarding — cliente que migra y se lleva su data”
scripts/export-org.ts
const ORG_ID = 'cnm-cigars'
const members = await db.select().from(member).where(eq(member.organizationId, ORG_ID))
const userIds = members.map(m => m.userId)
const users = await db.select().from(user).where(inArray(user.id, userIds))
const accounts = await db.select().from(account).where(inArray(account.userId, userIds))
fs.writeFileSync(`export-${ORG_ID}.json`, JSON.stringify({ users, members, accounts }, null, 2))
// scripts/offboard-org.ts — cuidado con usuarios compartidos entre proyectos
const ORG_ID = 'cnm-cigars'
const members = await db.select().from(member).where(eq(member.organizationId, ORG_ID))
for (const m of members) {
await db.delete(member).where(eq(member.id, m.id)) // 1. quita acceso a ESTE proyecto
const remaining = await db.select().from(member).where(eq(member.userId, m.userId))
if (remaining.length === 0) { // 2. solo borra el user si no queda en ningún otro proyecto
await db.delete(account).where(eq(account.userId, m.userId))
await db.delete(user).where(eq(user.id, m.userId))
}
}

9. Migración de la auth actual de ladder-api

Section titled “9. Migración de la auth actual de ladder-api”
  1. Levantar auth-service con su propia DB Postgres.
  2. Crear la organización ladder-api.
  3. Migrar filas ladder-api.user → auth-service.user (mismo id).
  4. Crear member con organizationId = 'ladder-api' por cada usuario migrado.
  5. Migrar account (passwords); session es opcional — forzar re-login es más simple.
  6. Apagar el Better Auth local de ladder-api, dejar solo el middleware que lee X-User-Id del gateway.
  7. Apuntar ladder-web a auth-service para login/signup, y al gateway para las llamadas a la API.
migrate-ladder-users.ts
const ORG_ID = 'ladder-api'
const users = await oldDb.select().from(oldUser)
for (const u of users) {
await newDb.insert(newUser).values({
id: u.id, name: u.name, email: u.email, emailVerified: u.emailVerified,
image: u.image, createdAt: u.createdAt, updatedAt: u.updatedAt,
}).onConflictDoNothing() // si el email ya existe (otro proyecto lo migró antes), no duplica
await newDb.insert(member).values({ id: randomUUID(), userId: u.id, organizationId: ORG_ID, role: 'member' })
}
const accounts = await oldDb.select().from(oldAccount)
for (const a of accounts) {
await newDb.insert(newAccount).values({
id: a.id, userId: a.userId, providerId: a.providerId, accountId: a.accountId, password: a.password,
}).onConflictDoNothing()
}

Correr en entorno controlado, con backup de ambas DBs antes.

  • auth-service desplegado con dominio público propio (auth.ladderdev.com)
  • Organización ladder-api creada
  • Script de migración corrido y verificado
  • gateway con ruta /api/ladder apuntando a ladder-api
  • ladder-api en red interna, sin puerto público
  • Middleware de ladder-api leyendo X-User-Id + validando X-Gateway-Secret
  • ladder-web apuntando login/signup a auth-service, resto de llamadas al gateway
  • Better Auth local de ladder-api desinstalado
  • Sesiones viejas invalidadas (forzar re-login una vez)

Nombres reales (ver src/env.ts, .env.example, better-auth.config.ts) — todas vía Infisical en producción, nunca hardcodeadas:

PORT=4000
DATABASE_URL=postgres://...
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=https://auth.ladderdev.com
CORS_ORIGIN=https://gateway.ladderdev.com,https://ladderdev.com
ADMIN_API_KEY= # para POST /api/flags, header X-Admin-Key
GATEWAY_SHARED_SECRET= # mismo valor que en ladder-gateway, valida origen del proxy
COOKIE_DOMAIN=.ladderdev.com # cross-subdomain cookie, para que ladder-web reciba la sesión
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GLITCHTIP_DSN= # opcional, error tracking (no-op si vacío)

Ver diagrama interactivo