Skip to content

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 connues

Composants

ComposantRôle
auth/src/lib/auth-session-client.jsSDK JS pour les apps consommatrices
auth/src/pages/SessionStatusPage.vuePage 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.sqlTable 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 automatiquement

init() 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 via heriade-auth-session:refresh → si non, redirige vers /login?returnTo=…

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 - 60s pour 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-status retombe sur la session maître Supabase et laisse /auth-app/authorize vé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/authorize après validation Supabase

4. Révocation de token (déconnexion)

Anciennement : Map&lt;userId, revokedAfter&gt; 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 :

  1. POST /auth-app/revoke → upsert dans user_token_revocations avec revoked_after = now()
  2. Cache in-process (30s TTL) invalidé pour ce userId
  3. À l'introspection : si token.iat &lt;= 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'app

6. É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

FichierNature
auth/src/lib/auth-session-client.jsNouveau — SDK SSO pour apps consommatrices
auth/src/pages/SessionStatusPage.vueModifié — réponses postMessage ciblées, broadcast SIGNED_OUT, suivi des parents
back/src/auth-app/auth-app.service.tsModifié — révocation persistante, cache 30s, introspect async
console/supabase/migrations/20260504120000_create_user_token_revocations.sqlNouveau — table de révocation
back/src/auth-app/auth-app.service.spec.tsNouveau — 14 tests unitaires
docs/architecture/overview.mdModifié — 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 push

Vé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

VariableServiceDescription
APP_ACCESS_TOKEN_SECRETback/Secret HMAC pour signer les app_access_token
AUTH_INTROSPECTION_SECRETback/Secret pour l'endpoint /introspect
AUTH_APP_ALLOWED_LOCAL_ORIGINSback/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.ts

Les 14 tests couvrent :

  • revokeCurrentUserTokens : upsert DB, erreurs, invalidation cache
  • introspect : 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)

  1. Se connecter sur http://localhost:5173 (auth portal)
  2. Ouvrir une app consommatrice configurée avec auth-session-client.js
  3. Vérifier dans les DevTools → Network : aucun rechargement de page lors du rafraîchissement de token
  4. Dans la console JS de l'app : await session.getAppToken() doit retourner un token sans reload

Test manuel — déconnexion globale

  1. Ouvrir l'auth portal et une app consommatrice dans deux onglets
  2. Se déconnecter depuis l'auth portal
  3. 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

  1. Obtenir un app_access_token via POST /auth-app/authorize
  2. Vérifier POST /auth-app/introspect{ active: true }
  3. Appeler POST /auth-app/revoke (avec le Bearer Supabase de l'utilisateur)
  4. 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.