Appearance
SSO et gestion de session inter-apps
Contexte et problème résolu
Avant cette implémentation, le rafraîchissement de token déclenchait un rechargement complet de page (window.location.reload()). Le lead dev demandait :
- TTL court sur les
app_access_token(sans impacter l'UX) - Rafraîchissement silencieux — zéro rechargement de page
- Déconnexion globale : se déconnecter sur une app déconnecte partout
- Connexion automatique : être connecté sur l'auth portal suffit pour les autres apps
Architecture mise en place
Vue d'ensemble
┌─────────────────────────────────────────────────────────┐
│ App consommatrice (bdd, crm…) │
│ │
│ auth-session-client.js hidden iframe │
│ ┌──────────────────────┐ ┌───────────────────┐ │
│ │ createAuthSession() │◄──────►│ /session-status │ │
│ │ │postMsg │ (auth.heriade.fr) │ │
│ │ - init() │ │ │ │
│ │ - getAppToken() │ │ SessionStatusPage │ │
│ │ - destroy() │ │ .vue │ │
│ └──────────────────────┘ └───────────────────┘ │
│ │ │
└──────────────────────────────────────────┼───────────────┘
│ Supabase
│ onAuthStateChange
▼
Broadcast SIGNED_OUT
→ toutes les apps connuesComposants
| Composant | Rôle |
|---|---|
auth/src/lib/auth-session-client.js | SDK JS pour les apps consommatrices |
auth/src/pages/SessionStatusPage.vue | Page iframe de l'auth portal — oracle de session |
back/src/auth-app/auth-app.service.ts | Émission et révocation des app_access_token |
console/supabase/migrations/20260504120000_create_user_token_revocations.sql | Table de révocation persistante |
Fonctionnement détaillé
1. Initialisation côté app consommatrice
js
const session = createAuthSession({
authUrl: 'https://auth.heriade.fr',
application: 'bdd',
environment: 'production',
onTokenRefreshed: ({ token, expiresAt }) => {
sessionStorage.setItem('app-auth', JSON.stringify({ token, expiresAt }))
},
onSignedOut: () => router.push('/login'),
})
const ok = await session.init()
if (!ok) return // redirigé vers /login automatiquementinit() crée un <iframe> caché pointant vers /session-status. Dès le chargement de l'iframe :
- Si l'app a déjà un token (post-callback OAuth) → le SDK l'utilise directement
- Sinon → postMessage
heriade-auth-session:check→ si session active, obtient un token viaheriade-auth-session:refresh→ si non, redirige vers/login?returnTo=…
Callback mobile via Android App Link
Pour une app Android / Capacitor, window.location.href peut valoir https://localhost/.... Cette origin ne doit pas etre envoyee au SSO pour l'application contacts, car l'environnement attend l'origin publique configuree dans environment_applications.url, par exemple https://contact.heriade.fr.
Le SDK accepte donc un returnTo explicite :
js
import {
buildAppCallbackUrl,
createAuthSession,
} from './auth-session-client'
const session = createAuthSession({
authUrl: 'https://auth.heriade.fr',
application: 'contacts',
environment: 'default',
returnTo: buildAppCallbackUrl('https://contact.heriade.fr', '/'),
// ou pour conserver une route interne :
// returnTo: () => buildAppCallbackUrl('https://contact.heriade.fr', window.location.pathname),
})Android doit ensuite associer le domaine a l'app via un App Link :
xml
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="contact.heriade.fr"
android:pathPrefix="/login/callback" />
</intent-filter>Le fichier public https://contact.heriade.fr/.well-known/assetlinks.json doit contenir le package_name Android et les fingerprints SHA-256 de signature.
2. Rafraîchissement silencieux
js
const token = await session.getAppToken()
// Jamais de reload — tout passe par postMessage- Token valide > 60s avant expiration → retourné depuis le cache mémoire
- Token proche de l'expiration →
doRefresh()via postMessage vers l'iframe - Proactive refresh : un timer est planifié à
expiresAt - 60spour rafraîchir avant que le token expire, sans bloquer les appels API - Watchdog silencieux : le SDK retente au retour de focus, au retour réseau et toutes les 60 secondes, utile quand un onglet a été suspendu en arrière-plan
- Compte cible : si le cache local du compte demandé est expiré,
/session-statusretombe sur la session maître Supabase et laisse/auth-app/authorizevérifier l'accès au compte - 401 applicatif : l'app doit appeler
getAppToken({ forceRefresh: true }), rejouer la requête avec le nouveau token, puis seulement rediriger si aucun token n'est retourné
Exemple de wrapper HTTP :
js
async function fetchWithAuth(input, init = {}) {
const token = await session.getAppToken()
const response = await fetch(input, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${token}`,
},
})
if (response.status !== 401) return response
const refreshedToken = await session.getAppToken({ forceRefresh: true })
if (!refreshedToken) return response
return fetch(input, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${refreshedToken}`,
},
})
}3. app_access_token
- TTL : 300 secondes (5 minutes)
- Format : JWT HMAC-SHA256 signé avec
APP_ACCESS_TOKEN_SECRET - Claims :
sub(userId),app,env,iat,exp,jti, données profil - Émis par
POST /auth-app/authorizeaprès validation Supabase
4. Révocation de token (déconnexion)
Anciennement : Map<userId, revokedAfter> en mémoire → perdu au redémarrage, non partagé entre instances.
Maintenant :
sql
-- Table persistante dans Supabase
CREATE TABLE public.user_token_revocations (
user_id UUID PRIMARY KEY,
revoked_after BIGINT NOT NULL, -- timestamp Unix de révocation
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Flux de révocation :
POST /auth-app/revoke→ upsert dansuser_token_revocationsavecrevoked_after = now()- Cache in-process (30s TTL) invalidé pour ce
userId - À l'introspection : si
token.iat <= revoked_after→{ active: false }
Cache de 30s : évite un aller-retour DB à chaque appel API, tout en garantissant la propagation en moins de 30s après une révocation.
Comportement en cas d'erreur DB : le service renvoie { active: true } (fail-open) plutôt que de bloquer tous les utilisateurs. Un WARN est loggé.
5. Déconnexion globale (SIGNED_OUT broadcast)
SessionStatusPage.vue écoute supabase.auth.onAuthStateChange. Quand Supabase émet SIGNED_OUT (ex : expiration de refresh token, déconnexion manuelle) :
SessionStatusPage
→ broadcastSignedOut()
→ postMessage { type: 'heriade-auth-session:signed-out' }
→ vers chaque origin connue (apps qui avaient communiqué)Côté SDK (auth-session-client.js) :
handleMessage (SIGNED_OUT)
→ cachedToken = null
→ clearTimeout(refreshTimer)
→ onSignedOut?.() // callback fourni par l'app6. Échanges postMessage
/session-status répond à l'origine exacte qui a émis le message (event.origin). Côté application consommatrice, les réponses sont acceptées uniquement si elles proviennent de l'origine configurée dans AUTH_APP_URL.
L'accès réel à une application reste validé côté backend par /auth-app/authorize avec le couple application / environment.
Fichiers modifiés
| Fichier | Nature |
|---|---|
auth/src/lib/auth-session-client.js | Nouveau — SDK SSO pour apps consommatrices |
auth/src/pages/SessionStatusPage.vue | Modifié — réponses postMessage ciblées, broadcast SIGNED_OUT, suivi des parents |
back/src/auth-app/auth-app.service.ts | Modifié — révocation persistante, cache 30s, introspect async |
console/supabase/migrations/20260504120000_create_user_token_revocations.sql | Nouveau — table de révocation |
back/src/auth-app/auth-app.service.spec.ts | Nouveau — 14 tests unitaires |
docs/architecture/overview.md | Modifié — diagramme SSO, exemples SDK |
Prérequis avant déploiement
Appliquer la migration Supabase
La table user_token_revocations n'existe pas encore en base. Sans elle, toute révocation et toute introspection échouera côté backend.
bash
# Dans le dossier console/
supabase db pushVérifier que la migration 20260504120000_create_user_token_revocations.sql est bien appliquée dans le dashboard Supabase avant de déployer le backend.
Variables d'environnement
| Variable | Service | Description |
|---|---|---|
APP_ACCESS_TOKEN_SECRET | back/ | Secret HMAC pour signer les app_access_token |
AUTH_INTROSPECTION_SECRET | back/ | Secret pour l'endpoint /introspect |
AUTH_APP_ALLOWED_LOCAL_ORIGINS | back/ | Origins localhost explicitement autorisees par /auth-app/authorize pour les tests hybrides staging/local |
Comment tester
Tests unitaires (automatisés)
bash
cd back
yarn test
# 74 tests, 13 suites — inclut les 14 nouveaux tests auth-app.service.spec.tsLes 14 tests couvrent :
revokeCurrentUserTokens: upsert DB, erreurs, invalidation cacheintrospect: secret manquant/invalide, token vide/invalide, token actif, token révoqué, token post-révocation, fail-open sur erreur DB- Cache TTL : hit dans les 30s, re-query après expiration
Test manuel — SSO check (app déjà connectée)
- Se connecter sur
http://localhost:5173(auth portal) - Ouvrir une app consommatrice configurée avec
auth-session-client.js - Vérifier dans les DevTools → Network : aucun rechargement de page lors du rafraîchissement de token
- Dans la console JS de l'app :
await session.getAppToken()doit retourner un token sans reload
Test manuel — déconnexion globale
- Ouvrir l'auth portal et une app consommatrice dans deux onglets
- Se déconnecter depuis l'auth portal
- Vérifier que l'app consommatrice appelle
onSignedOut(redirection vers/login) sans rechargement de page complet — uniquement via le callback
Test manuel — révocation de token
- Obtenir un
app_access_tokenviaPOST /auth-app/authorize - Vérifier
POST /auth-app/introspect→{ active: true } - Appeler
POST /auth-app/revoke(avec le Bearer Supabase de l'utilisateur) - Re-vérifier
POST /auth-app/introspect→{ active: false }(dans les 30s)
Test — comportement de l'iframe
Dans les DevTools de l'app consommatrice, onglet Messages (ou dans la console) :
js
// Inspecter les messages postMessage entrants
window.addEventListener('message', console.log)On doit voir les types :
heriade-auth-session:result(réponse au check)heriade-auth-session:refresh-result(réponse au refresh)heriade-auth-session:signed-out(en cas de déconnexion)
Intégration dans une nouvelle app consommatrice
js
// 1. Copier auth/src/lib/auth-session-client.js dans l'app
// 2. Initialiser au démarrage
import { createAuthSession } from './auth-session-client.js'
const session = createAuthSession({
authUrl: 'https://auth.heriade.fr',
application: 'mon-app', // nom_technique dans la table applications
environment: 'production',
onTokenRefreshed: ({ token, expiresAt }) => {
localStorage.setItem('app-auth', JSON.stringify({ token, expiresAt }))
},
onSignedOut: () => router.push('/login'),
})
// Cas 1 : après callback OAuth (token dans le hash URL)
const ok = await session.init({ token: hashToken, expiresAt: hashExpiresAt })
// Cas 2 : SSO automatique (l'app démarre sans token local)
const ok = await session.init()
if (!ok) return // déjà redirigé vers /login
// 3. Avant chaque appel API
const token = await session.getAppToken()
fetch('/api/data', { headers: { Authorization: `Bearer ${token}` } })
// 3 bis. Après un 401, refresh forcé puis retry
const refreshedToken = await session.getAppToken({ forceRefresh: true })
if (refreshedToken) {
fetch('/api/data', { headers: { Authorization: `Bearer ${refreshedToken}` } })
}
// 4. Nettoyage (ex: beforeUnmount)
session.destroy()Configurer AUTH_APP_URL, APPLICATION et ENVIRONMENT côté app consommatrice. Le portail AUTH valide ensuite l'accès via /auth-app/authorize.
