Documentation publique

Guide d’intégration backend Node.js

Créez une session MFA après validation du mot de passe, redirigez l’utilisateur vers la page hébergée, puis introspectez la session côté serveur avant de créer la session utilisateur finale.

Flow sécurisé API v1
  1. Valider email et mot de passe dans l’application cliente.
  2. Créer une session MFA via API backend.
  3. Rediriger vers hosted_url.
  4. Introspecter mfa_session et mfa_token.
  5. Créer la session finale seulement si valid = true.

Introduction

Tana MFA fonctionne comme une page MFA hébergée. Votre backend garde la responsabilité du login primaire, appelle l’API Tana MFA avec une clé serveur, puis attend une introspection valide avant d’ouvrir la session applicative.

Ne créez jamais la session utilisateur finale avant une introspection serveur réussie et une vérification de external_user_id.

Prérequis

  • Une application Tana MFA active avec une clé API serveur.
  • Un backend Node.js capable d’effectuer des appels HTTPS sortants.
  • Des routes de retour côté client : succès et annulation MFA.
  • Un stockage de session ou de cache temporaire pour le state anti-CSRF.
  • HTTPS en production et clé API conservée uniquement côté serveur.

Variables d’environnement

Utilisez ces placeholders dans vos exemples locaux, puis remplacez-les par les valeurs de votre application.

API_BASE_URL=https://api.example.com
API_KEY=YOUR_API_KEY
WEBHOOK_SECRET=YOUR_SECRET

WEBHOOK_SECRET est un placeholder réservé aux callbacks ou webhooks que votre application pourrait gérer. Les endpoints MFA listés ci-dessous utilisent uniquement API_BASE_URL et API_KEY.

Endpoints disponibles

Méthode Endpoint Usage Auth
GET /health Vérifier que le service répond. Public
GET /api/v1/status Vérifier l’API et la clé API. Bearer
POST /api/v1/mfa/sessions Créer une session MFA hébergée. Bearer
GET /api/v1/mfa/sessions/{session_id} Lire l’état d’une session MFA. Bearer
POST /api/v1/mfa/sessions/introspect Valider une session après retour utilisateur. Bearer
POST /api/v1/mfa/users/sync Synchroniser un utilisateur MFA. Bearer
POST /api/v1/mfa/diagnose Diagnostiquer la décision MFA pour un utilisateur. Bearer

Headers, payloads et réponses

Créer une session MFA

Méthode
POST
Endpoint
/api/v1/mfa/sessions
Headers
  • Content-Type: application/json
  • Authorization: Bearer YOUR_API_KEY

Payload attendu

{
  "external_user_id": "user_123",
  "email": "developer@example.com",
  "roles": ["ROLE_ADMIN"],
  "success_url": "https://client.example.com/mfa/success?state=STATE",
  "cancel_url": "https://client.example.com/mfa/cancel?state=STATE",
  "country_code": "MG"
}

Réponse JSON attendue

{
  "success": true,
  "data": {
    "session_id": "mfa_sess_xxx",
    "status": "challenge_required",
    "requirement": "required",
    "next_action": "challenge_required",
    "hosted_url": "https://api.example.com/mfa/s/mfa_sess_xxx",
    "expires_at": "2026-06-19T10:30:00+03:00",
    "verification_token": null
  }
}

Introspecter une session MFA

Méthode
POST
Endpoint
/api/v1/mfa/sessions/introspect
Headers
  • Content-Type: application/json
  • Authorization: Bearer YOUR_API_KEY

Payload attendu

{
  "session_id": "mfa_sess_xxx",
  "verification_token": "mfa_ver_xxx"
}

Réponse JSON attendue

{
  "success": true,
  "data": {
    "valid": true,
    "session_id": "mfa_sess_xxx",
    "status": "verified",
    "verified_at": "2026-06-19T10:13:48+03:00",
    "external_user_id": "user_123",
    "email": "developer@example.com",
    "roles": ["ROLE_ADMIN"],
    "mfa_enabled": true,
    "application": {
      "id": 2,
      "name": "Application cliente"
    }
  }
}

Réponse d’erreur possible

{
  "success": true,
  "data": {
    "valid": false,
    "reason": "invalid_verification_token"
  }
}

Format d’erreur API standard

Méthode
POST
Endpoint
/api/v1/mfa/sessions
Headers
  • Content-Type: application/json
  • Authorization: Bearer YOUR_API_KEY

Payload attendu

{
  "external_user_id": "",
  "email": "developer@example.com",
  "roles": []
}

Réponse d’erreur possible

{
  "success": false,
  "error": {
    "code": "validation_error",
    "message": "The field external_user_id is required.",
    "details": {
      "field": "external_user_id"
    }
  }
}

Exemples de code complets

Node.js natif

import http from 'node:http';
import crypto from 'node:crypto';

const API_BASE_URL = process.env.API_BASE_URL ?? 'https://api.example.com';
const API_KEY = process.env.API_KEY ?? 'YOUR_API_KEY';
const pendingStates = new Map();

async function tanaMfa(path, options = {}) {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`,
      ...(options.headers ?? {}),
    },
  });

  const payload = await response.json();

  if (!response.ok || payload.success === false) {
    throw new Error(payload.error?.message ?? 'Tana MFA API error');
  }

  return payload.data;
}

function redirect(res, location) {
  res.writeHead(302, { Location: location });
  res.end();
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'https://client.example.com');

  if (req.method === 'POST' && url.pathname === '/login') {
    const externalUserId = 'user_123';
    const email = 'developer@example.com';
    const state = crypto.randomBytes(32).toString('hex');
    pendingStates.set(state, { externalUserId, email });

    const session = await tanaMfa('/api/v1/mfa/sessions', {
      method: 'POST',
      body: JSON.stringify({
        external_user_id: externalUserId,
        email,
        roles: ['ROLE_ADMIN'],
        success_url: `https://client.example.com/mfa/success?state=${state}`,
        cancel_url: `https://client.example.com/mfa/cancel?state=${state}`,
      }),
    });

    return redirect(res, session.hosted_url);
  }

  if (req.method === 'GET' && url.pathname === '/mfa/success') {
    const state = url.searchParams.get('state');
    const pending = pendingStates.get(state);

    if (!pending) {
      res.writeHead(403);
      return res.end('Invalid state');
    }

    const introspection = await tanaMfa('/api/v1/mfa/sessions/introspect', {
      method: 'POST',
      body: JSON.stringify({
        session_id: url.searchParams.get('mfa_session'),
        verification_token: url.searchParams.get('mfa_token'),
      }),
    });

    if (!introspection.valid || introspection.external_user_id !== pending.externalUserId) {
      res.writeHead(403);
      return res.end('Invalid MFA session');
    }

    pendingStates.delete(state);
    res.writeHead(200);
    return res.end('Authenticated');
  }

  res.writeHead(404);
  res.end('Not found');
});

server.listen(3000);

Express.js

import express from 'express';
import session from 'express-session';
import crypto from 'node:crypto';

const app = express();
const API_BASE_URL = process.env.API_BASE_URL ?? 'https://api.example.com';
const API_KEY = process.env.API_KEY ?? 'YOUR_API_KEY';

app.use(express.json());
app.use(session({
  secret: process.env.WEBHOOK_SECRET ?? 'YOUR_SECRET',
  resave: false,
  saveUninitialized: false,
}));

async function tanaMfa(path, body) {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`,
    },
    body: JSON.stringify(body),
  });

  const payload = await response.json();
  if (!response.ok || payload.success === false) {
    throw new Error(payload.error?.message ?? 'Tana MFA API error');
  }

  return payload.data;
}

app.post('/login', async (req, res, next) => {
  try {
    const user = { id: 'user_123', email: 'developer@example.com', roles: ['ROLE_ADMIN'] };
    const state = crypto.randomBytes(32).toString('hex');

    req.session.pendingMfa = { state, externalUserId: user.id };

    const session = await tanaMfa('/api/v1/mfa/sessions', {
      external_user_id: user.id,
      email: user.email,
      roles: user.roles,
      success_url: `https://client.example.com/mfa/success?state=${state}`,
      cancel_url: `https://client.example.com/mfa/cancel?state=${state}`,
    });

    res.redirect(session.hosted_url);
  } catch (error) {
    next(error);
  }
});

app.get('/mfa/success', async (req, res, next) => {
  try {
    const pending = req.session.pendingMfa;
    if (!pending || req.query.state !== pending.state) {
      return res.status(403).send('Invalid state');
    }

    const introspection = await tanaMfa('/api/v1/mfa/sessions/introspect', {
      session_id: req.query.mfa_session,
      verification_token: req.query.mfa_token,
    });

    if (!introspection.valid || introspection.external_user_id !== pending.externalUserId) {
      return res.status(403).send('Invalid MFA session');
    }

    delete req.session.pendingMfa;
    req.session.user = { externalUserId: introspection.external_user_id };
    res.redirect('/dashboard');
  } catch (error) {
    next(error);
  }
});

app.listen(3000);

Fastify

import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import session from '@fastify/session';
import crypto from 'node:crypto';

const app = Fastify();
const API_BASE_URL = process.env.API_BASE_URL ?? 'https://api.example.com';
const API_KEY = process.env.API_KEY ?? 'YOUR_API_KEY';

await app.register(cookie);
await app.register(session, {
  secret: process.env.WEBHOOK_SECRET ?? 'a-secret-with-at-least-32-characters',
  cookie: { secure: true, sameSite: 'lax' },
});

async function tanaMfa(path, body) {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`,
    },
    body: JSON.stringify(body),
  });

  const payload = await response.json();
  if (!response.ok || payload.success === false) {
    throw new Error(payload.error?.message ?? 'Tana MFA API error');
  }

  return payload.data;
}

app.post('/login', async (request, reply) => {
  const user = { id: 'user_123', email: 'developer@example.com', roles: ['ROLE_ADMIN'] };
  const state = crypto.randomBytes(32).toString('hex');

  request.session.pendingMfa = { state, externalUserId: user.id };

  const session = await tanaMfa('/api/v1/mfa/sessions', {
    external_user_id: user.id,
    email: user.email,
    roles: user.roles,
    success_url: `https://client.example.com/mfa/success?state=${state}`,
    cancel_url: `https://client.example.com/mfa/cancel?state=${state}`,
  });

  return reply.redirect(session.hosted_url);
});

app.get('/mfa/success', async (request, reply) => {
  const pending = request.session.pendingMfa;

  if (!pending || request.query.state !== pending.state) {
    return reply.code(403).send({ error: 'Invalid state' });
  }

  const introspection = await tanaMfa('/api/v1/mfa/sessions/introspect', {
    session_id: request.query.mfa_session,
    verification_token: request.query.mfa_token,
  });

  if (!introspection.valid || introspection.external_user_id !== pending.externalUserId) {
    return reply.code(403).send({ error: 'Invalid MFA session' });
  }

  request.session.pendingMfa = null;
  request.session.user = { externalUserId: introspection.external_user_id };

  return reply.redirect('/dashboard');
});

await app.listen({ port: 3000 });

NestJS

import { Body, Controller, Get, Injectable, Post, Query, Redirect, Req, ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'node:crypto';

@Injectable()
export class TanaMfaService {
  constructor(private readonly config: ConfigService) {}

  private async post(path: string, body: unknown) {
    const response = await fetch(`${this.config.get('API_BASE_URL') ?? 'https://api.example.com'}${path}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.config.get('API_KEY') ?? 'YOUR_API_KEY'}`,
      },
      body: JSON.stringify(body),
    });

    const payload = await response.json();
    if (!response.ok || payload.success === false) {
      throw new Error(payload.error?.message ?? 'Tana MFA API error');
    }

    return payload.data;
  }

  createSession(data: Record<string, unknown>) {
    return this.post('/api/v1/mfa/sessions', data);
  }

  introspect(data: Record<string, unknown>) {
    return this.post('/api/v1/mfa/sessions/introspect', data);
  }
}

@Controller()
export class AuthController {
  constructor(private readonly tanaMfa: TanaMfaService) {}

  @Post('/login')
  @Redirect()
  async login(@Req() req: any, @Body() body: any) {
    const user = { id: 'user_123', email: 'developer@example.com', roles: ['ROLE_ADMIN'] };
    const state = randomBytes(32).toString('hex');

    req.session.pendingMfa = { state, externalUserId: user.id };

    const session = await this.tanaMfa.createSession({
      external_user_id: user.id,
      email: user.email,
      roles: user.roles,
      success_url: `https://client.example.com/mfa/success?state=${state}`,
      cancel_url: `https://client.example.com/mfa/cancel?state=${state}`,
    });

    return { url: session.hosted_url };
  }

  @Get('/mfa/success')
  async success(@Req() req: any, @Query() query: any) {
    const pending = req.session.pendingMfa;

    if (!pending || query.state !== pending.state) {
      throw new ForbiddenException('Invalid state');
    }

    const introspection = await this.tanaMfa.introspect({
      session_id: query.mfa_session,
      verification_token: query.mfa_token,
    });

    if (!introspection.valid || introspection.external_user_id !== pending.externalUserId) {
      throw new ForbiddenException('Invalid MFA session');
    }

    delete req.session.pendingMfa;
    req.session.user = { externalUserId: introspection.external_user_id };

    return { authenticated: true };
  }
}

Bonnes pratiques

  • Gardez API_KEY exclusivement côté serveur.
  • Générez un state aléatoire par tentative de login et vérifiez-le au retour.
  • Comparez toujours external_user_id après introspection.
  • Traitez valid: false comme un refus d’authentification, même si le retour URL contient status=verified.
  • Ne loggez jamais les tokens, secrets TOTP, backup codes ou validateurs de trusted device.
  • Utilisez HTTPS, des cookies de session sécurisés et une expiration courte du pending login.

Dépannage / FAQ

Pourquoi ne pas connecter l’utilisateur dès le retour success_url ?

Le retour navigateur peut être forgé. Le backend doit appeler l’introspection avec le token de vérification et vérifier valid = true ainsi que external_user_id.

Que faire si l’introspection retourne valid: false ?

Refusez le login et redirigez l’utilisateur vers l’écran de connexion. La raison peut être session_not_verified, session_expired, application_mismatch ou invalid_verification_token.

Où stocker le pending user ?

Dans une session serveur, un cache court ou une table temporaire. Ne le stockez pas uniquement côté frontend.

Pourquoi ma requête API retourne 401 ?

Vérifiez le header Authorization: Bearer YOUR_API_KEY, la révocation, l’expiration et l’environnement de la clé API.