import User from '#models/user' import Organization from '#models/organization' import { provisionDefaultPlans } from '#services/default_plans' import { issueRefreshToken } from '#services/refresh_token' import db from '@adonisjs/lucid/services/db' import crypto from 'node:crypto' import type { HttpContext } from '@adonisjs/core/http' export type SsoProvider = 'google' | 'microsoft' /** * Identité minimale extraite d'un provider OAuth (cf. AllyUserContract). * Une seule shape pour tous les providers — on normalise au boundary du * driver Ally vers ce type. */ export type SsoIdentity = { provider: SsoProvider /** sub stable du provider (google_id ou microsoft_id en DB). */ providerId: string /** Email — peut être null pour certains comptes Microsoft, dans ce cas * on le rejette en amont. */ email: string /** Nom complet si dispo. */ fullName: string | null } /** * Trouve ou crée l'utilisateur pour une identité SSO : * 1. Match par `_id` (canonique) * 2. Sinon match par email → lie l'ID provider au compte existant * 3. Sinon crée un nouvel user + org + plans par défaut * * Retourne `{ user, isNewUser }`. Aux callers de poser le refresh cookie * et de décider la redirection. */ export async function findOrCreateUserFromSso( identity: SsoIdentity ): Promise<{ user: User; isNewUser: boolean }> { const idColumn = identity.provider === 'google' ? 'googleId' : 'microsoftId' // 1. Match canonique provider_id let user = await User.findBy(idColumn, identity.providerId) if (user) return { user, isNewUser: false } // 2. Match email — première connexion via ce provider d'un user existant. user = await User.findBy('email', identity.email.toLowerCase()) if (user) { ;(user as unknown as Record)[idColumn] = identity.providerId if (!user.fullName && identity.fullName) { user.fullName = identity.fullName } await user.save() return { user, isNewUser: false } } // 3. Création : nouvelle org + plans par défaut + user (mdp aléatoire // inutilisable, le user pourra activer email/password via "mdp oublié"). user = await db.transaction(async (trx) => { const org = await Organization.create({ name: '' }, { client: trx }) await provisionDefaultPlans(org.id, trx) return User.create( { email: identity.email.toLowerCase(), fullName: identity.fullName, password: crypto.randomBytes(48).toString('base64url'), [idColumn]: identity.providerId, organizationId: org.id, } as unknown as Partial, { client: trx } ) }) return { user, isNewUser: true } } /** * Calcule la prochaine étape d'onboarding pour un user qui vient de se * connecter via SSO. La route SPA cible cette URL (passée via ?next=...). * * - new user → /onboarding/entreprise (compte déjà rempli * par les infos SSO, pas besoin de cette étape) * - org name vide → /onboarding/entreprise * - signature manquante → /onboarding/signature * - sinon → / (dashboard) */ export async function nextRouteAfterSso( user: User, isNewUser: boolean ): Promise { if (isNewUser) return '/onboarding/entreprise' const org = user.organizationId ? await Organization.find(user.organizationId) : null if (!org || !org.name) return '/onboarding/entreprise' if (!user.signature) return '/onboarding/signature' return '/' } /** * Pose le refresh cookie httpOnly puis redirige vers le SPA en passant * la route cible. Utilisé en fin de callback SSO. */ export async function emitSsoSessionAndRedirect( ctx: HttpContext, user: User, next: string, webUrl: string ): Promise { await issueRefreshToken(user, ctx) ctx.response.redirect(`${webUrl}/auth/sso/complete?next=${encodeURIComponent(next)}`) }