Praxsuite

Autenticación

Vincent Depassier · 30 de agosto de 2026

Autenticación de Usuarios Finales

Todo lo que cuelga de /{workspaceId}/auth — qué llamar, qué mandar y qué vuelve.

https://gateway.praxsuite.com/{workspaceId}/auth/...

Qué necesita cada endpoint

Leé esta tabla antes que nada. La mayoría de los problemas de integración acá son una credencial equivocada, no un cuerpo equivocado.

Endpoint

Necesita

GET /auth/config

nada — totalmente público

GET /auth/confirm-email

nada — totalmente público, se abre desde un correo

POST /auth/register

una API key (pk_live_ o sk_live_)

POST /auth/login

una API key

POST /auth/refresh

una API key

POST /auth/logout

una API key

POST /auth/forgot-password

una API key

POST /auth/verify-reset-code

una API key

POST /auth/reset-password

una API key

POST /auth/resend-confirmation

una API key

POST /auth/change-password

el JWT del propio usuario final

GET /auth/oidc/{providerSlug}

una API key

POST /auth/oidc/callback

una API key

El requisito de API key es deliberado: significa que solo un llamador con una key legítima del workspace puede crear o autenticar cuentas en él. Una key pk_live_ alcanza — estos endpoints están pensados para llamarse desde el navegador.

change-password es la excepción en el otro sentido: cambia la contraseña de quien sostiene el token, así que toma el token y no una key.


Arrancar un cliente de navegador

Un frontend necesita una key publicable antes de poder llamar a nada. GET /auth/config se la entrega, sin autenticación alguna:

GET https://gateway.praxsuite.com/{workspaceId}/auth/config

Devuelve la key pk_live_ del workspace. Es seguro por diseño — una key publicable es análoga a una publishable key de Stripe, y su poder viene de sus scopes, no de su secreto. Te ahorra hornearla en un build, lo que a su vez significa que rotarla no implica volver a desplegar.


Registro

POST /{workspaceId}/auth/register
Authorization: Bearer pk_live_xxxxxxxx
Content-Type: application/json

{
  "email": "ana.rojas@example.com",
  "password": "…",
  "firstName": "Ana",
  "lastName": "Rojas"
}

Crea una cuenta local y manda el correo de confirmación. La cuenta puede loguearse antes de confirmar — emailVerified reporta el estado y tu app decide qué habilita.


Login

POST /{workspaceId}/auth/login
Authorization: Bearer pk_live_xxxxxxxx
Content-Type: application/json

{ "email": "ana.rojas@example.com", "password": "…" }
{
  "isSuccess": true,
  "data": {
    "accessToken": "eyJhbGciOi...",
    "refreshToken": "…",
    "accessTokenExpiresAt": "2026-08-30T18:40:00Z",
    "refreshTokenExpiresAt": "2026-09-29T18:10:00Z",
    "tokenType": "Bearer",
    "user": { "id": "…", "email": "ana.rojas@example.com" }
  }
}

Si el workspace tiene un proveedor OIDC configurado, el login pasa por él de forma transparente — el gateway valida las credenciales contra el proveedor y devuelve su propio token. Sin redirección de navegador, sin cambios en tu cliente. Si no hay proveedor, cae en la autenticación local.


Qué hay dentro del token de acceso

El JWT es un token firmado normal, y sus claims son lo que leen los row filters y el Event Bus:

Claim

Valor

sub

El id del usuario final — este es el que usan los row filters

email

Su correo

workspace

El id del workspace

auth_type

Siempre gateway_enduser

role

Una entrada por cada nombre de rol que tiene

role_id

Una entrada por cada id de rol que tiene

given_name / family_name

Presentes cuando el perfil los tiene

jti, iat

Id del token y momento de emisión

Cualquiera de esta lista se puede referenciar desde el row filter de un rol con valueFromClaim, o desde el valor por defecto de una columna con {{claim:…}}. sub es el que vas a usar el noventa por ciento de las veces.


Refresh, y por qué importa la rotación

POST /{workspaceId}/auth/refresh
Authorization: Bearer pk_live_xxxxxxxx

{ "refreshToken": "…" }

Recibís un token de acceso nuevo y un refresh token nuevo. El que mandaste queda invalidado en la misma operación.

Eso tiene una consecuencia práctica: guardá solo el refresh token más reciente. Si dos pestañas refrescan a la vez, una gana y la otra queda con un token que ya no sirve. Serializá los refresh en un solo lugar de tu cliente, o aceptá que la pestaña perdedora tenga que volver a loguearse.

El refresh token son 64 bytes aleatorios y se guarda solo como hash, junto con la IP y el user agent que lo crearon — que es lo que hace útil a la lista de sesiones del portal.


Logout

POST /{workspaceId}/auth/logout
{ "refreshToken": "…" }

Revoca ese único refresh token — un dispositivo, una sesión. No toca las otras sesiones del usuario, y no invalida un token de acceso ya emitido: ese muere cuando vence.

Para terminar todas las sesiones de una, desactivá la cuenta (o usá el reset de contraseña de administrador, que también revoca sesiones).


Reset de contraseña, en tres pasos

El flujo son tres llamadas y no un enlace, deliberadamente, porque un OTP que llega por correo no debería servir mucho tiempo.

  forgot-password ──▶ código de 6 dígitos por correo   (válido 15 minutos)
        │
        ▼
  verify-reset-code ──▶ token de sesión corto          (válido 10 minutos)
        │
        ▼
  reset-password ──▶ contraseña cambiada
POST /auth/forgot-password     { "email": "…" }
POST /auth/verify-reset-code   { "email": "…", "code": "123456" }
POST /auth/reset-password      { "sessionToken": "…", "newPassword": "…" }

`forgot-password` siempre devuelve 200, exista o no la dirección. Lo mismo hace resend-confirmation. No es un bug a sortear: evita que el endpoint sirva para descubrir qué direcciones están registradas. Tu UI debería decir "si esa dirección existe, te mandamos un código" y decirlo en serio.

Un administrador puede en cambio enviar un enlace de reset desde la pestaña End Users, válido por 60 minutos, que le permite a la persona poner su propia contraseña. Preferí eso antes que setear la contraseña por ella.


Cambiar la contraseña estando logueado

POST /{workspaceId}/auth/change-password
Authorization: Bearer <el JWT del usuario final>

{ "currentPassword": "…", "newPassword": "…" }

Es el único endpoint de auth que toma el token del usuario final en vez de una API key.


Login con un proveedor externo

Un workspace puede delegar la autenticación a cualquier proveedor OIDC — un directorio corporativo, un servidor de identidad, un login social. Cada uno se registra con un slug, un nombre visible, una URL de discovery, un client id y un secreto (cifrado en reposo, y que la API nunca devuelve).

  GET  /auth/oidc/{providerSlug}   ──▶ { authorizationUrl }
        │
        │  redirigís el navegador ahí; el proveedor lo devuelve con ?code&state
        ▼
  POST /auth/oidc/callback  { code, state }  ──▶ el mismo par de tokens que un login local

El resultado es idéntico a un login local: un JWT del gateway con los mismos claims. Qué proveedor usó la persona queda registrado en la cuenta como authProvider y providerSubject.


Operaciones de administración

Viven en /{workspaceId}/endusers y toman una sesión del portal, no una API key — son la API de la propia pestaña End Users.

Operación

Efecto

Crear / actualizar / listar

Lo obvio

Desactivar

Pone isActive en falso y revoca todas las sesiones

Eliminar (permanente)

Irreversible, se lleva los datos relacionados

Enviar reset de contraseña

Manda un enlace de 60 minutos para que la persona la ponga

Setear contraseña

La setea directo y revoca sus sesiones activas

Reenviar confirmación

Invalida los tokens pendientes y manda uno nuevo

Asignar / quitar roles

Lo único que cambia lo que puede ver

Importar

Validar un archivo primero, después ejecutar


Cómo se conecta todo

const GATEWAY = "https://gateway.praxsuite.com";
const WORKSPACE = "3fa85f64-5717-4562-b3fc-2c963f66afa6";

// 1. Traé la key publicable una vez, al arrancar. No necesita auth.
const { publishableKey } = await fetch(`${GATEWAY}/${WORKSPACE}/auth/config`)
  .then((r) => r.json());

// 2. Logueá al usuario con ella.
async function login(email, password) {
  const res = await fetch(`${GATEWAY}/${WORKSPACE}/auth/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${publishableKey}`,
    },
    body: JSON.stringify({ email, password }),
  });
  const { data } = await res.json();
  // Guardá solo el refresh token más nuevo — refrescar lo rota.
  localStorage.setItem("refresh", data.refreshToken);
  return data.accessToken;
}

// 3. De acá en adelante, cada llamada lleva el token del usuario, no la key.
async function misPedidos(accessToken) {
  return fetch(`${GATEWAY}/${WORKSPACE}/query`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    // Sin cláusula "los míos" — el row filter del rol la agrega del lado del servidor.
    body: JSON.stringify({
      refs: { Pedidos: "…" },
      query: { from: "Pedidos", limit: 25 },
    }),
  }).then((r) => r.json());
}

El comentario de la última llamada es el punto entero del sistema: el cliente no pide sus propias filas, y no podría pedir las de otro.


Siguiente