From 7521e1fff6783d5bea0a72ec6aab80db4fa9387f Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 7 May 2026 09:38:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Microsoft=20365=20SSO=20+=20facto?= =?UTF-8?q?risation=20helper=20SSO=20partag=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/deploy-memory.md | 24 +++ apps/api/.env.example | 14 ++ .../app/controllers/auth_google_controller.ts | 104 ++--------- .../controllers/auth_microsoft_controller.ts | 55 ++++++ .../api/app/services/ally/microsoft_driver.ts | 163 ++++++++++++++++++ apps/api/app/services/sso_session.ts | 108 ++++++++++++ apps/api/config/ally.ts | 22 ++- ...001400_add_microsoft_sso_to_users_table.ts | 25 +++ apps/api/database/schema.ts | 4 +- apps/api/start/env.ts | 6 + apps/api/start/routes.ts | 6 + .../auth/{GoogleButton.tsx => SsoButton.tsx} | 50 ++++-- ...gle.complete.tsx => auth.sso.complete.tsx} | 10 +- apps/web/src/routes/login.tsx | 43 +++-- apps/web/src/routes/signup.tsx | 7 +- k3s/app/api.yml | 7 + 16 files changed, 523 insertions(+), 125 deletions(-) create mode 100644 apps/api/app/controllers/auth_microsoft_controller.ts create mode 100644 apps/api/app/services/ally/microsoft_driver.ts create mode 100644 apps/api/app/services/sso_session.ts create mode 100644 apps/api/database/migrations/1778080001400_add_microsoft_sso_to_users_table.ts rename apps/web/src/components/auth/{GoogleButton.tsx => SsoButton.tsx} (61%) rename apps/web/src/routes/{auth.google.complete.tsx => auth.sso.complete.tsx} (89%) diff --git a/.claude/deploy-memory.md b/.claude/deploy-memory.md index 51704fe..51628e0 100644 --- a/.claude/deploy-memory.md +++ b/.claude/deploy-memory.md @@ -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. diff --git a/apps/api/.env.example b/apps/api/.env.example index 11c5a2f..65511da 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/app/controllers/auth_google_controller.ts b/apps/api/app/controllers/auth_google_controller.ts index ce5e110..b107517 100644 --- a/apps/api/app/controllers/auth_google_controller.ts +++ b/apps/api/app/controllers/auth_google_controller.ts @@ -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) } } diff --git a/apps/api/app/controllers/auth_microsoft_controller.ts b/apps/api/app/controllers/auth_microsoft_controller.ts new file mode 100644 index 0000000..7993a47 --- /dev/null +++ b/apps/api/app/controllers/auth_microsoft_controller.ts @@ -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) + } +} diff --git a/apps/api/app/services/ally/microsoft_driver.ts b/apps/api/app/services/ally/microsoft_driver.ts new file mode 100644 index 0000000..e9b4946 --- /dev/null +++ b/apps/api/app/services/ally/microsoft_driver.ts @@ -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 + * - `` : 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 | . 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 + implements AllyDriverContract +{ + 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) { + 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, '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> { + 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> { + 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) +} diff --git a/apps/api/app/services/sso_session.ts b/apps/api/app/services/sso_session.ts new file mode 100644 index 0000000..f5504cc --- /dev/null +++ b/apps/api/app/services/sso_session.ts @@ -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 `_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)}`) +} diff --git a/apps/api/config/ally.ts b/apps/api/config/ally.ts index 0c3426a..64e7c58 100644 --- a/apps/api/config/ally.ts +++ b/apps/api/config/ally.ts @@ -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 diff --git a/apps/api/database/migrations/1778080001400_add_microsoft_sso_to_users_table.ts b/apps/api/database/migrations/1778080001400_add_microsoft_sso_to_users_table.ts new file mode 100644 index 0000000..90bbdf2 --- /dev/null +++ b/apps/api/database/migrations/1778080001400_add_microsoft_sso_to_users_table.ts @@ -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') + }) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index fc7cdd4..759ecc4 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -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 diff --git a/apps/api/start/env.ts b/apps/api/start/env.ts index 570cffd..45f2e4b 100644 --- a/apps/api/start/env.ts +++ b/apps/api/start/env.ts @@ -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 diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 4594beb..447bd59 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -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') diff --git a/apps/web/src/components/auth/GoogleButton.tsx b/apps/web/src/components/auth/SsoButton.tsx similarity index 61% rename from apps/web/src/components/auth/GoogleButton.tsx rename to apps/web/src/components/auth/SsoButton.tsx index 8309ee9..03e48f0 100644 --- a/apps/web/src/components/auth/GoogleButton.tsx +++ b/apps/web/src/components/auth/SsoButton.tsx @@ -1,27 +1,43 @@ import { cn } from "@/lib/utils"; /** - * Bouton "Continuer avec Google". + * Bouton SSO réutilisable (Google, Microsoft, …). * - * IMPORTANT — c'est un ``, PAS un bouton fetch : + * IMPORTANT — c'est un ``, 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 = { + google: "Continuer avec Google", + microsoft: "Continuer avec Microsoft", +}; + +const LOGOS: Record JSX.Element> = { + google: () => - ); } -/** Logo Google officiel — 4 couleurs, taille fixe 18px. */ +/** Logo Google officiel — 4 couleurs, 18px. */ function GoogleLogo(props: React.SVGProps) { return ( @@ -63,7 +79,19 @@ function GoogleLogo(props: React.SVGProps) { ); } -/** Séparateur "ou" entre le bouton SSO et le formulaire email/password. */ +/** Logo Microsoft officiel — 4 carrés, 18px. */ +function MicrosoftLogo(props: React.SVGProps) { + return ( + + + + + + + ); +} + +/** Séparateur "ou" entre la pile SSO et le formulaire email/password. */ export function AuthDivider({ label = "ou" }: { label?: string }) { return (
diff --git a/apps/web/src/routes/auth.google.complete.tsx b/apps/web/src/routes/auth.sso.complete.tsx similarity index 89% rename from apps/web/src/routes/auth.google.complete.tsx rename to apps/web/src/routes/auth.sso.complete.tsx index 09f8553..6078cea 100644 --- a/apps/web/src/routes/auth.google.complete.tsx +++ b/apps/web/src/routes/auth.sso.complete.tsx @@ -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. diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 71429cb..1a2e86d 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -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 = { - 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> = { + 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() {

-
- +
+ +
diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx index 6281843..8ed8997 100644 --- a/apps/web/src/routes/signup.tsx +++ b/apps/web/src/routes/signup.tsx @@ -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() {

-
- +
+ +
diff --git a/k3s/app/api.yml b/k3s/app/api.yml index 114c667..9c7edf8 100644 --- a/k3s/app/api.yml +++ b/k3s/app/api.yml @@ -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'