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:
ordinarthur 2026-05-07 09:38:38 +02:00
parent ea539cd1d4
commit 7521e1fff6
16 changed files with 523 additions and 125 deletions

View File

@ -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.

View File

@ -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

View File

@ -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)
}
}

View 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)
}
}

View 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)
}

View 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)}`)
}

View File

@ -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

View File

@ -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')
})
}
}

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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">

View File

@ -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.

View File

@ -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>

View File

@ -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>

View File

@ -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'