rubis/apps/api/app/services/sso_session.ts
ordinarthur 7521e1fff6 feat(auth): Microsoft 365 SSO + factorisation helper SSO partagé
Backend
- Custom Ally driver Microsoft (Oauth2Driver) — Microsoft n'est pas dans
  les providers built-in, mais le driver dérive de Oauth2Driver en quelques
  lignes. Endpoints v2.0 (Microsoft Identity Platform), Graph /me pour le
  profil, fallback userPrincipalName si mail null (comptes perso).
- Tenant configurable via MICROSOFT_TENANT (défaut 'common' — accepte
  work/school + perso ; 'organizations' pour M365 strict).
- Migration 1400 : ajout microsoft_id nullable unique sur users.
- AuthMicrosoftController : redirect + callback (même pattern que Google).
- Refacto : extraction d'un service sso_session.ts (findOrCreateUserFromSso,
  nextRouteAfterSso, emitSsoSessionAndRedirect) → AuthGoogle + AuthMicrosoft
  partagent la logique.
- Routes /api/v1/auth/microsoft/{redirect,callback}.

Frontend
- Composant SsoButton générique (provider="google"|"microsoft") avec logo
  officiel inline pour chaque. Remplace l'ancien GoogleButton.
- Login + signup : pile verticale "Continuer avec Google" + "Continuer
  avec Microsoft", puis séparateur "ou", puis form email/password.
- Route SPA renommée /auth/google/complete → /auth/sso/complete (partagée
  entre les deux providers, la callback API redirige toujours dessus).
- Erreurs SSO sur /login : ?google=... ET ?microsoft=... → toast contextuel.

K3s
- ConfigMap rubis-api-config : ajout MICROSOFT_TENANT + MICROSOFT_CALLBACK_URL.
- Secret rubis-app-secrets : ajout MICROSOFT_CLIENT_ID + MICROSOFT_CLIENT_SECRET.

Doc
- .claude/deploy-memory.md : procédure Azure / Entra ID app registration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:38:38 +02:00

109 lines
3.8 KiB
TypeScript

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 `<provider>_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<string, unknown>)[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<User>,
{ 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<string> {
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<void> {
await issueRefreshToken(user, ctx)
ctx.response.redirect(`${webUrl}/auth/sso/complete?next=${encodeURIComponent(next)}`)
}