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>
109 lines
3.8 KiB
TypeScript
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)}`)
|
|
}
|