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>
This commit is contained in:
parent
ea539cd1d4
commit
7521e1fff6
@ -111,6 +111,30 @@ peut se connecter), il faut passer en "In production" (vérification Google
|
||||
si scopes sensibles ; les nôtres `userinfo.email` + `userinfo.profile` sont
|
||||
non-sensibles, validation auto).
|
||||
|
||||
### Microsoft SSO — setup Azure / Entra ID
|
||||
1. https://portal.azure.com → **Microsoft Entra ID** → **App registrations**
|
||||
→ **New registration**
|
||||
2. **Name** : `Rubis Sur l'Ongle` ; **Supported account types** :
|
||||
- "Accounts in any organizational directory and personal Microsoft accounts"
|
||||
(tenant=common, recommandé)
|
||||
- ou "Accounts in any organizational directory" (tenant=organizations,
|
||||
M365 strict)
|
||||
3. **Redirect URI** type **Web** :
|
||||
- `https://app.rubis.arthurbarre.fr/api/v1/auth/microsoft/callback`
|
||||
4. Après création : ajouter en plus le redirect dev via **Authentication →
|
||||
Add a platform → Web** :
|
||||
- `http://localhost:3333/api/v1/auth/microsoft/callback`
|
||||
5. **Certificates & secrets → New client secret** : créer, copier la
|
||||
*Value* (visible une seule fois) → `MICROSOFT_CLIENT_SECRET`
|
||||
6. Page **Overview** → copier *Application (client) ID* → `MICROSOFT_CLIENT_ID`
|
||||
7. **API permissions** : `User.Read` (déjà délégué par défaut), pas besoin
|
||||
d'admin consent pour les comptes individuels
|
||||
8. Mettre les valeurs dans `apps/api/.env` (dev) et `rubis-app-secrets` (prod).
|
||||
|
||||
Le client secret expire (Azure force 6 ou 12 mois max) — penser à le
|
||||
renouveler avant échéance ; sinon les nouvelles connexions échoueront
|
||||
en silence après expiration.
|
||||
|
||||
### Mise à jour
|
||||
Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths
|
||||
modifiés. Build+rollout indépendants.
|
||||
|
||||
@ -87,4 +87,18 @@ COOKIE_SECURE=false
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3333/api/v1/auth/google/callback
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Microsoft SSO (Ally) — App registration sur https://portal.azure.com
|
||||
# (Microsoft Entra ID → App registrations → New registration → Web),
|
||||
# redirect URIs à enregistrer :
|
||||
# - http://localhost:3333/api/v1/auth/microsoft/callback (dev)
|
||||
# - https://app.rubis.arthurbarre.fr/api/v1/auth/microsoft/callback (prod)
|
||||
# Tenant : 'common' (work + perso), 'organizations' (M365 only) ou un GUID.
|
||||
#--------------------------------------------------------------------
|
||||
MICROSOFT_CLIENT_ID=
|
||||
MICROSOFT_CLIENT_SECRET=
|
||||
MICROSOFT_TENANT=common
|
||||
MICROSOFT_CALLBACK_URL=http://localhost:3333/api/v1/auth/microsoft/callback
|
||||
|
||||
LIMITER_STORE=redis
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import User from '#models/user'
|
||||
import Organization from '#models/organization'
|
||||
import { provisionDefaultPlans } from '#services/default_plans'
|
||||
import { issueRefreshToken } from '#services/refresh_token'
|
||||
import {
|
||||
emitSsoSessionAndRedirect,
|
||||
findOrCreateUserFromSso,
|
||||
nextRouteAfterSso,
|
||||
} from '#services/sso_session'
|
||||
import env from '#start/env'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Google SSO via @adonisjs/ally.
|
||||
@ -15,36 +14,20 @@ import crypto from 'node:crypto'
|
||||
* 1. Le SPA navigate vers GET /api/v1/auth/google/redirect
|
||||
* 2. Ally redirige vers Google avec state + scopes
|
||||
* 3. Google redirige vers GET /api/v1/auth/google/callback?code=...
|
||||
* 4. Backend matche/crée l'user, pose le refresh cookie (httpOnly)
|
||||
* 5. Redirige le browser vers le SPA sur /auth/google/complete?next=...
|
||||
* 6. Le SPA appelle POST /api/v1/auth/refresh (cookie auto-envoyé) → reçoit
|
||||
* un access token, navigue vers `next`.
|
||||
*
|
||||
* On NE retourne PAS d'access token en JSON ici car la callback est un
|
||||
* redirect server-side : pas de body lisible par le SPA.
|
||||
* 4. Backend matche/crée l'user (cf. findOrCreateUserFromSso),
|
||||
* pose le refresh cookie, redirige vers /auth/sso/complete?next=...
|
||||
*/
|
||||
export default class AuthGoogleController {
|
||||
/**
|
||||
* GET /api/v1/auth/google/redirect — entrée du flow OAuth.
|
||||
* Le bouton "Continuer avec Google" pointe directement ici (pas un fetch).
|
||||
*/
|
||||
/** GET /api/v1/auth/google/redirect — entrée du flow OAuth. */
|
||||
async redirect(ctx: HttpContext) {
|
||||
return ctx.ally.use('google').redirect()
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/google/callback — retour de Google.
|
||||
*
|
||||
* Stratégie de matching :
|
||||
* 1. Existing user with `google_id` ? → log in
|
||||
* 2. Existing user with same `email` ? → link `google_id` + log in
|
||||
* 3. New user → crée org + plans par défaut + user (password null)
|
||||
*/
|
||||
/** GET /api/v1/auth/google/callback — retour de Google. */
|
||||
async callback(ctx: HttpContext) {
|
||||
const google = ctx.ally.use('google')
|
||||
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
|
||||
|
||||
// Erreurs côté Google (canceled, mismatch, etc.)
|
||||
if (google.accessDenied()) {
|
||||
return ctx.response.redirect(`${webUrl}/login?google=denied`)
|
||||
}
|
||||
@ -62,67 +45,14 @@ export default class AuthGoogleController {
|
||||
return ctx.response.redirect(`${webUrl}/login?google=no_email`)
|
||||
}
|
||||
|
||||
// 1. Lookup par google_id (canonique)
|
||||
let user = await User.findBy('googleId', googleUser.id)
|
||||
let isNewUser = false
|
||||
const { user, isNewUser } = await findOrCreateUserFromSso({
|
||||
provider: 'google',
|
||||
providerId: googleUser.id,
|
||||
email: googleUser.email,
|
||||
fullName: googleUser.name ?? null,
|
||||
})
|
||||
|
||||
// 2. Fallback email — première connexion d'un user email/password
|
||||
// via Google, on lie son google_id à son compte existant.
|
||||
if (!user) {
|
||||
user = await User.findBy('email', googleUser.email.toLowerCase())
|
||||
if (user) {
|
||||
user.googleId = googleUser.id
|
||||
if (!user.fullName && googleUser.name) {
|
||||
user.fullName = googleUser.name
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Création
|
||||
if (!user) {
|
||||
isNewUser = true
|
||||
user = await db.transaction(async (trx) => {
|
||||
const org = await Organization.create({ name: '' }, { client: trx })
|
||||
await provisionDefaultPlans(org.id, trx)
|
||||
return User.create(
|
||||
{
|
||||
email: googleUser.email!.toLowerCase(),
|
||||
fullName: googleUser.name ?? null,
|
||||
// password requis dans le schéma Lucid (string), mais nullable
|
||||
// en DB. On stocke un random unguessable que personne ne peut
|
||||
// utiliser pour login email/password (User.verifyCredentials
|
||||
// hash-comparera et échouera). Le user pourra plus tard
|
||||
// activer email/password via "mot de passe oublié".
|
||||
password: crypto.randomBytes(48).toString('base64url'),
|
||||
googleId: googleUser.id,
|
||||
organizationId: org.id,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Pose le refresh cookie httpOnly (path /api/v1/auth)
|
||||
await issueRefreshToken(user, ctx)
|
||||
|
||||
// Décide où renvoyer l'utilisateur.
|
||||
// Org name vide = onboarding entreprise pas terminé → on saute "compte"
|
||||
// (Google nous a déjà donné nom + email) et on enchaîne sur entreprise.
|
||||
let next = '/'
|
||||
if (isNewUser) {
|
||||
next = '/onboarding/entreprise'
|
||||
} else {
|
||||
const org = user.organizationId
|
||||
? await Organization.find(user.organizationId)
|
||||
: null
|
||||
if (!org || !org.name) {
|
||||
next = '/onboarding/entreprise'
|
||||
} else if (!user.signature) {
|
||||
next = '/onboarding/signature'
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.response.redirect(`${webUrl}/auth/google/complete?next=${encodeURIComponent(next)}`)
|
||||
const next = await nextRouteAfterSso(user, isNewUser)
|
||||
return emitSsoSessionAndRedirect(ctx, user, next, webUrl)
|
||||
}
|
||||
}
|
||||
|
||||
55
apps/api/app/controllers/auth_microsoft_controller.ts
Normal file
55
apps/api/app/controllers/auth_microsoft_controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
emitSsoSessionAndRedirect,
|
||||
findOrCreateUserFromSso,
|
||||
nextRouteAfterSso,
|
||||
} from '#services/sso_session'
|
||||
import env from '#start/env'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* Microsoft 365 SSO via le custom Ally driver (services/ally/microsoft_driver.ts).
|
||||
*
|
||||
* Flow identique à Google (cf. AuthGoogleController). Le tenant utilisé
|
||||
* est défini dans config/ally.ts (par défaut `common` : couvre les comptes
|
||||
* work/school + comptes personnels Microsoft).
|
||||
*/
|
||||
export default class AuthMicrosoftController {
|
||||
/** GET /api/v1/auth/microsoft/redirect — entrée OAuth. */
|
||||
async redirect(ctx: HttpContext) {
|
||||
return ctx.ally.use('microsoft').redirect()
|
||||
}
|
||||
|
||||
/** GET /api/v1/auth/microsoft/callback — retour de Microsoft. */
|
||||
async callback(ctx: HttpContext) {
|
||||
const microsoft = ctx.ally.use('microsoft')
|
||||
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
|
||||
|
||||
if (microsoft.accessDenied()) {
|
||||
return ctx.response.redirect(`${webUrl}/login?microsoft=denied`)
|
||||
}
|
||||
if (microsoft.stateMisMatch()) {
|
||||
return ctx.response.redirect(`${webUrl}/login?microsoft=state_mismatch`)
|
||||
}
|
||||
if (microsoft.hasError()) {
|
||||
logger.warn({ err: microsoft.getError() }, 'microsoft sso error')
|
||||
return ctx.response.redirect(`${webUrl}/login?microsoft=error`)
|
||||
}
|
||||
|
||||
const msUser = await microsoft.user()
|
||||
if (!msUser.email) {
|
||||
logger.warn({ id: msUser.id }, 'microsoft sso : email manquant')
|
||||
return ctx.response.redirect(`${webUrl}/login?microsoft=no_email`)
|
||||
}
|
||||
|
||||
const { user, isNewUser } = await findOrCreateUserFromSso({
|
||||
provider: 'microsoft',
|
||||
providerId: msUser.id,
|
||||
email: msUser.email,
|
||||
fullName: msUser.name ?? null,
|
||||
})
|
||||
|
||||
const next = await nextRouteAfterSso(user, isNewUser)
|
||||
return emitSsoSessionAndRedirect(ctx, user, next, webUrl)
|
||||
}
|
||||
}
|
||||
163
apps/api/app/services/ally/microsoft_driver.ts
Normal file
163
apps/api/app/services/ally/microsoft_driver.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { Oauth2Driver } from '@adonisjs/ally'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type {
|
||||
AllyDriverContract,
|
||||
AllyUserContract,
|
||||
ApiRequestContract,
|
||||
Oauth2DriverConfig,
|
||||
RedirectRequestContract,
|
||||
} from '@adonisjs/ally/types'
|
||||
|
||||
/**
|
||||
* Driver Ally Microsoft Identity (v2.0). Couvre Microsoft 365 (work/school)
|
||||
* et les comptes personnels (Outlook, Hotmail, Live) — choix du tenant
|
||||
* via la prop `tenant` (défaut: `common`).
|
||||
*
|
||||
* Endpoints :
|
||||
* - Authorize : https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
|
||||
* - Token : https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
|
||||
* - User info : https://graph.microsoft.com/v1.0/me (retourne id, displayName,
|
||||
* mail, userPrincipalName, givenName, surname)
|
||||
*
|
||||
* Tenants possibles :
|
||||
* - `common` : tout (work/school + personal). Notre défaut.
|
||||
* - `organizations` : work/school uniquement (Microsoft 365 strict)
|
||||
* - `consumers` : personal uniquement
|
||||
* - `<tenant-id>` : un tenant Azure AD spécifique
|
||||
*/
|
||||
|
||||
export type MicrosoftDriverAccessToken = {
|
||||
token: string
|
||||
type: 'bearer'
|
||||
refreshToken?: string
|
||||
expiresIn?: number
|
||||
expiresAt?: Date
|
||||
idToken?: string
|
||||
}
|
||||
|
||||
export type MicrosoftDriverScopes =
|
||||
| 'openid'
|
||||
| 'profile'
|
||||
| 'email'
|
||||
| 'offline_access'
|
||||
| 'User.Read'
|
||||
| 'User.ReadBasic.All'
|
||||
|
||||
export type MicrosoftDriverConfig = Oauth2DriverConfig & {
|
||||
scopes?: MicrosoftDriverScopes[]
|
||||
/** common | organizations | consumers | <tenant-id>. Défaut: common. */
|
||||
tenant?: string
|
||||
/** Force l'écran de sélection de compte. Pratique en dev. */
|
||||
prompt?: 'login' | 'none' | 'consent' | 'select_account'
|
||||
}
|
||||
|
||||
type MicrosoftGraphMeResponse = {
|
||||
id: string
|
||||
displayName: string | null
|
||||
givenName: string | null
|
||||
surname: string | null
|
||||
mail: string | null
|
||||
userPrincipalName: string | null
|
||||
}
|
||||
|
||||
export class MicrosoftDriver
|
||||
extends Oauth2Driver<MicrosoftDriverAccessToken, MicrosoftDriverScopes>
|
||||
implements AllyDriverContract<MicrosoftDriverAccessToken, MicrosoftDriverScopes>
|
||||
{
|
||||
protected accessTokenUrl: string
|
||||
protected authorizeUrl: string
|
||||
protected userInfoUrl = 'https://graph.microsoft.com/v1.0/me'
|
||||
|
||||
protected codeParamName = 'code'
|
||||
protected errorParamName = 'error'
|
||||
protected stateCookieName = 'microsoft_oauth_state'
|
||||
protected stateParamName = 'state'
|
||||
protected scopeParamName = 'scope'
|
||||
protected scopesSeparator = ' '
|
||||
|
||||
constructor(
|
||||
ctx: HttpContext,
|
||||
public config: MicrosoftDriverConfig
|
||||
) {
|
||||
super(ctx, config)
|
||||
const tenant = config.tenant || 'common'
|
||||
this.accessTokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`
|
||||
this.authorizeUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`
|
||||
this.loadState()
|
||||
}
|
||||
|
||||
protected configureRedirectRequest(request: RedirectRequestContract<MicrosoftDriverScopes>) {
|
||||
request.scopes(this.config.scopes || ['openid', 'profile', 'email', 'User.Read'])
|
||||
request.param('response_type', 'code')
|
||||
request.param('response_mode', 'query')
|
||||
if (this.config.prompt) {
|
||||
request.param('prompt', this.config.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
/** access_denied / consent_required / interaction_required → l'user a annulé. */
|
||||
accessDenied(): boolean {
|
||||
const error = this.getError()
|
||||
if (!error) return false
|
||||
return ['access_denied', 'consent_required', 'interaction_required'].includes(error)
|
||||
}
|
||||
|
||||
protected getAuthenticatedRequest(url: string, token: string) {
|
||||
const request = this.httpClient(url)
|
||||
request.header('Authorization', `Bearer ${token}`)
|
||||
request.header('Accept', 'application/json')
|
||||
request.parseAs('json')
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me sur Microsoft Graph. `mail` peut être null pour des comptes
|
||||
* perso → fallback sur `userPrincipalName` (qui est toujours rempli et
|
||||
* ressemble à un email).
|
||||
*/
|
||||
protected async getUserInfo(
|
||||
token: string,
|
||||
callback?: (request: ApiRequestContract) => void
|
||||
): Promise<Omit<AllyUserContract<MicrosoftDriverAccessToken>, 'token'>> {
|
||||
const request = this.getAuthenticatedRequest(this.userInfoUrl, token)
|
||||
if (typeof callback === 'function') callback(request)
|
||||
|
||||
const body = (await request.get()) as MicrosoftGraphMeResponse
|
||||
const email = body.mail ?? body.userPrincipalName
|
||||
return {
|
||||
id: body.id,
|
||||
nickName: body.displayName ?? body.givenName ?? email ?? body.id,
|
||||
name: body.displayName ?? [body.givenName, body.surname].filter(Boolean).join(' ') ?? '',
|
||||
email: email,
|
||||
// Microsoft Graph ne renvoie pas explicitement un état de vérification.
|
||||
// On considère l'email vérifié (Microsoft contrôle ses propres domaines).
|
||||
emailVerificationState: 'verified' as const,
|
||||
avatarUrl: null,
|
||||
original: body,
|
||||
}
|
||||
}
|
||||
|
||||
async user(
|
||||
callback?: (request: ApiRequestContract) => void
|
||||
): Promise<AllyUserContract<MicrosoftDriverAccessToken>> {
|
||||
const token = await this.accessToken(callback)
|
||||
const user = await this.getUserInfo(token.token, callback)
|
||||
return { ...user, token }
|
||||
}
|
||||
|
||||
async userFromToken(
|
||||
token: string,
|
||||
callback?: (request: ApiRequestContract) => void
|
||||
): Promise<AllyUserContract<{ token: string; type: 'bearer' }>> {
|
||||
const user = await this.getUserInfo(token, callback)
|
||||
return { ...user, token: { token, type: 'bearer' as const } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper de config (équivalent de `services.google()` mais pour Microsoft).
|
||||
* On l'enregistre dans `config/ally.ts`.
|
||||
*/
|
||||
export function microsoftService(config: MicrosoftDriverConfig) {
|
||||
return (ctx: HttpContext) => new MicrosoftDriver(ctx, config)
|
||||
}
|
||||
108
apps/api/app/services/sso_session.ts
Normal file
108
apps/api/app/services/sso_session.ts
Normal file
@ -0,0 +1,108 @@
|
||||
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)}`)
|
||||
}
|
||||
@ -1,15 +1,14 @@
|
||||
import env from '#start/env'
|
||||
import { defineConfig, services } from '@adonisjs/ally'
|
||||
import { microsoftService } from '#services/ally/microsoft_driver'
|
||||
|
||||
/**
|
||||
* Configuration des providers OAuth (Ally).
|
||||
*
|
||||
* V1 : Google uniquement (cf. CLAUDE.md → Auth). Les autres viendront
|
||||
* plus tard si pertinent (Microsoft pour les TPE qui utilisent O365 ?).
|
||||
*
|
||||
* Le callback URL pointe vers l'API en interne (/api/v1/auth/google/callback).
|
||||
* V1 : Google + Microsoft 365 (cf. CLAUDE.md → Auth).
|
||||
* Le callback URL pointe vers l'API (/api/v1/auth/{provider}/callback).
|
||||
* En prod, le reverse proxy nginx (rubis-web) achemine /api/* vers ce
|
||||
* service, donc la même URL fonctionne pour le browser et pour Google.
|
||||
* service, donc la même URL fonctionne browser et provider.
|
||||
*/
|
||||
const allyConfig = defineConfig({
|
||||
google: services.google({
|
||||
@ -25,6 +24,19 @@ const allyConfig = defineConfig({
|
||||
scopes: ['userinfo.email', 'userinfo.profile'],
|
||||
prompt: 'select_account',
|
||||
}),
|
||||
microsoft: microsoftService({
|
||||
clientId: env.get('MICROSOFT_CLIENT_ID', ''),
|
||||
clientSecret: env.get('MICROSOFT_CLIENT_SECRET', ''),
|
||||
callbackUrl: env.get(
|
||||
'MICROSOFT_CALLBACK_URL',
|
||||
'http://localhost:3333/api/v1/auth/microsoft/callback'
|
||||
),
|
||||
// tenant=common : accepte work/school (Microsoft 365) ET comptes personnels
|
||||
// (Outlook, Hotmail). Pour limiter à M365 strict, mettre 'organizations'.
|
||||
tenant: env.get('MICROSOFT_TENANT', 'common'),
|
||||
scopes: ['openid', 'profile', 'email', 'User.Read'],
|
||||
prompt: 'select_account',
|
||||
}),
|
||||
})
|
||||
|
||||
export default allyConfig
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Ajoute `microsoft_id` (sub OAuth Microsoft Identity, stable, unique).
|
||||
* Match prioritaire sur cet ID, fallback email pour lier un compte existant.
|
||||
*
|
||||
* Mêmes principes que google_id (cf. migration 1300) :
|
||||
* - Nullable (un user peut n'avoir que Google ou que email/password)
|
||||
* - Unique pour éviter les collisions
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'users'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('microsoft_id', 64).nullable().unique()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('microsoft_id')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -302,7 +302,7 @@ export class RelanceTaskSchema extends BaseModel {
|
||||
}
|
||||
|
||||
export class UserSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
||||
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
||||
$columns = UserSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@ -315,6 +315,8 @@ export class UserSchema extends BaseModel {
|
||||
@column({ isPrimary: true })
|
||||
declare id: string
|
||||
@column()
|
||||
declare microsoftId: string | null
|
||||
@column()
|
||||
declare organizationId: string | null
|
||||
@column({ serializeAs: null })
|
||||
declare password: string | null
|
||||
|
||||
@ -73,6 +73,12 @@ export default await Env.create(new URL('../', import.meta.url), {
|
||||
GOOGLE_CLIENT_SECRET: Env.schema.string.optional(),
|
||||
GOOGLE_CALLBACK_URL: Env.schema.string.optional({ format: 'url', tld: false }),
|
||||
|
||||
// Microsoft SSO (Ally)
|
||||
MICROSOFT_CLIENT_ID: Env.schema.string.optional(),
|
||||
MICROSOFT_CLIENT_SECRET: Env.schema.string.optional(),
|
||||
MICROSOFT_TENANT: Env.schema.string.optional(),
|
||||
MICROSOFT_CALLBACK_URL: Env.schema.string.optional({ format: 'url', tld: false }),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Variables for configuring the limiter package
|
||||
|
||||
@ -43,6 +43,12 @@ router
|
||||
router
|
||||
.get('google/callback', [controllers.AuthGoogle, 'callback'])
|
||||
.as('google.callback')
|
||||
router
|
||||
.get('microsoft/redirect', [controllers.AuthMicrosoft, 'redirect'])
|
||||
.as('microsoft.redirect')
|
||||
router
|
||||
.get('microsoft/callback', [controllers.AuthMicrosoft, 'callback'])
|
||||
.as('microsoft.callback')
|
||||
})
|
||||
.prefix('auth')
|
||||
.as('auth')
|
||||
|
||||
@ -1,27 +1,43 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Bouton "Continuer avec Google".
|
||||
* Bouton SSO réutilisable (Google, Microsoft, …).
|
||||
*
|
||||
* IMPORTANT — c'est un `<a href>`, PAS un bouton fetch :
|
||||
* IMPORTANT — c'est un `<a href>`, PAS un fetch button :
|
||||
* OAuth nécessite un full-page redirect (le browser doit naviguer vers
|
||||
* l'écran de consentement Google). Un fetch ne peut pas suivre les
|
||||
* redirections cross-origin avec cookies.
|
||||
* l'écran de consentement du provider avec ses cookies). Un fetch ne
|
||||
* peut pas suivre les redirections cross-origin avec cookies.
|
||||
*
|
||||
* L'URL est relative — nginx (rubis-web) proxy /api/* vers rubis-api,
|
||||
* donc même origine pour le browser → cookie refresh posé par la
|
||||
* callback est lisible côté SPA.
|
||||
*/
|
||||
export function GoogleButton({
|
||||
label = "Continuer avec Google",
|
||||
type SsoProvider = "google" | "microsoft";
|
||||
|
||||
const LABELS: Record<SsoProvider, string> = {
|
||||
google: "Continuer avec Google",
|
||||
microsoft: "Continuer avec Microsoft",
|
||||
};
|
||||
|
||||
const LOGOS: Record<SsoProvider, () => JSX.Element> = {
|
||||
google: () => <GoogleLogo aria-hidden="true" />,
|
||||
microsoft: () => <MicrosoftLogo aria-hidden="true" />,
|
||||
};
|
||||
|
||||
export function SsoButton({
|
||||
provider,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
provider: SsoProvider;
|
||||
/** Override label (par défaut "Continuer avec X"). */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const Logo = LOGOS[provider];
|
||||
return (
|
||||
<a
|
||||
href="/api/v1/auth/google/redirect"
|
||||
href={`/api/v1/auth/${provider}/redirect`}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2.5 w-full",
|
||||
"h-11 px-5 rounded-default border border-line bg-white",
|
||||
@ -33,13 +49,13 @@ export function GoogleButton({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GoogleLogo aria-hidden="true" />
|
||||
{label}
|
||||
<Logo />
|
||||
{label ?? LABELS[provider]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Logo Google officiel — 4 couleurs, taille fixe 18px. */
|
||||
/** Logo Google officiel — 4 couleurs, 18px. */
|
||||
function GoogleLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" {...props}>
|
||||
@ -63,7 +79,19 @@ function GoogleLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Séparateur "ou" entre le bouton SSO et le formulaire email/password. */
|
||||
/** Logo Microsoft officiel — 4 carrés, 18px. */
|
||||
function MicrosoftLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 23 23" {...props}>
|
||||
<path fill="#F25022" d="M1 1h10v10H1z" />
|
||||
<path fill="#7FBA00" d="M12 1h10v10H12z" />
|
||||
<path fill="#00A4EF" d="M1 12h10v10H1z" />
|
||||
<path fill="#FFB900" d="M12 12h10v10H12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Séparateur "ou" entre la pile SSO et le formulaire email/password. */
|
||||
export function AuthDivider({ label = "ou" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
@ -9,9 +9,9 @@ import { authStore } from "@/lib/auth";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
|
||||
/**
|
||||
* Callback Google côté SPA — se charge :
|
||||
* Callback SSO côté SPA — partagé entre Google et Microsoft. Se charge :
|
||||
* 1. d'appeler POST /api/v1/auth/refresh (le cookie httpOnly posé par
|
||||
* /api/v1/auth/google/callback est auto-envoyé)
|
||||
* la callback backend OAuth est auto-envoyé)
|
||||
* 2. de stocker l'access token + user dans authStore
|
||||
* 3. de naviguer vers `?next=...` (envoyé par le backend selon l'état
|
||||
* d'onboarding : "/" pour user complet, "/onboarding/entreprise"
|
||||
@ -23,12 +23,12 @@ const searchSchema = z.object({
|
||||
next: z.string().default("/"),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/auth/google/complete")({
|
||||
export const Route = createFileRoute("/auth/sso/complete")({
|
||||
validateSearch: searchSchema,
|
||||
component: GoogleCompletePage,
|
||||
component: SsoCompletePage,
|
||||
});
|
||||
|
||||
function GoogleCompletePage() {
|
||||
function SsoCompletePage() {
|
||||
const { next } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
// Strict-mode protect : avoid double-firing the refresh in dev.
|
||||
@ -16,18 +16,31 @@ import { Field } from "@/components/ui/Field";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
|
||||
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
|
||||
|
||||
const ssoErrorEnum = z
|
||||
.enum(["denied", "state_mismatch", "error", "no_email"])
|
||||
.optional();
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z.string().optional(),
|
||||
google: z.enum(["denied", "state_mismatch", "error", "no_email"]).optional(),
|
||||
google: ssoErrorEnum,
|
||||
microsoft: ssoErrorEnum,
|
||||
});
|
||||
|
||||
const GOOGLE_ERROR_MESSAGES: Record<string, string> = {
|
||||
denied: "Connexion Google annulée.",
|
||||
state_mismatch: "Session expirée, réessayez la connexion Google.",
|
||||
error: "Connexion Google impossible. Réessayez dans un instant.",
|
||||
no_email: "Votre compte Google n'a pas d'email associé.",
|
||||
const SSO_ERROR_MESSAGES: Record<string, Record<string, string>> = {
|
||||
google: {
|
||||
denied: "Connexion Google annulée.",
|
||||
state_mismatch: "Session expirée, réessayez la connexion Google.",
|
||||
error: "Connexion Google impossible. Réessayez dans un instant.",
|
||||
no_email: "Votre compte Google n'a pas d'email associé.",
|
||||
},
|
||||
microsoft: {
|
||||
denied: "Connexion Microsoft annulée.",
|
||||
state_mismatch: "Session expirée, réessayez la connexion Microsoft.",
|
||||
error: "Connexion Microsoft impossible. Réessayez dans un instant.",
|
||||
no_email: "Votre compte Microsoft n'a pas d'email associé.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
@ -39,12 +52,15 @@ function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
|
||||
// Toast d'erreur si on revient d'un échec Google SSO (?google=denied|...).
|
||||
// Toast d'erreur si on revient d'un échec SSO (?google=denied, ?microsoft=…).
|
||||
useEffect(() => {
|
||||
if (search.google && GOOGLE_ERROR_MESSAGES[search.google]) {
|
||||
toast.error(GOOGLE_ERROR_MESSAGES[search.google]!);
|
||||
for (const provider of ["google", "microsoft"] as const) {
|
||||
const code = search[provider];
|
||||
if (code && SSO_ERROR_MESSAGES[provider]?.[code]) {
|
||||
toast.error(SSO_ERROR_MESSAGES[provider]![code]!);
|
||||
}
|
||||
}
|
||||
}, [search.google]);
|
||||
}, [search.google, search.microsoft]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (input: LoginInput) =>
|
||||
@ -138,8 +154,9 @@ function LoginPage() {
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-7">
|
||||
<GoogleButton />
|
||||
<div className="mt-7 flex flex-col gap-2">
|
||||
<SsoButton provider="google" />
|
||||
<SsoButton provider="microsoft" />
|
||||
<AuthDivider />
|
||||
</div>
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
|
||||
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignupPage,
|
||||
@ -128,8 +128,9 @@ function SignupPage() {
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-7">
|
||||
<GoogleButton label="S'inscrire avec Google" />
|
||||
<div className="mt-7 flex flex-col gap-2">
|
||||
<SsoButton provider="google" label="S'inscrire avec Google" />
|
||||
<SsoButton provider="microsoft" label="S'inscrire avec Microsoft" />
|
||||
<AuthDivider />
|
||||
</div>
|
||||
|
||||
|
||||
@ -135,3 +135,10 @@ data:
|
||||
# Le callback URL doit matcher EXACTEMENT ce qui est configuré dans
|
||||
# Google Cloud Console (OAuth Client → Authorized redirect URIs).
|
||||
GOOGLE_CALLBACK_URL: 'https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback'
|
||||
|
||||
# Microsoft SSO — MICROSOFT_CLIENT_ID/SECRET sont dans rubis-app-secrets.
|
||||
# MICROSOFT_TENANT : 'common' (work + perso), 'organizations' (M365 only),
|
||||
# ou un tenant ID Azure AD spécifique. Le callback URL doit matcher
|
||||
# EXACTEMENT le redirect URI configuré côté Azure App registration.
|
||||
MICROSOFT_TENANT: 'common'
|
||||
MICROSOFT_CALLBACK_URL: 'https://app.rubis.arthurbarre.fr/api/v1/auth/microsoft/callback'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user