Ir al contenido
Pide tu alta

OAuth 2.0 con PKCE

En corto

Redirige al Dueño a /oauth/autorizar con PKCE S256, canjea el code por tokens en menos de 60 segundos, guarda el refresh_token cifrado (es rotativo: siempre el más nuevo) y valida state en tu callback — es tu única defensa contra CSRF.

Las apps públicas usan OAuth 2.0 con código de autorización y PKCE (obligatorio: no existe el método plain, solo S256). El Dueño del comercio acepta tus permisos en una pantalla de consentimiento y tu app recibe sus propios tokens, por instalación.

En la práctica, el Dueño llega a tu install_url (la que registraste al pedir el alta) haciendo clic en “Instalar” desde el catálogo de apps de Wink POS (Ajustes → Integraciones § Explorar apps). Wink POS abre tu install_url con ?business_id=<id> como pista del comercio, para que la preselecciones; el Dueño igual elige y aprueba el comercio en la pantalla de consentimiento, sin importar esa pista. Desde install_url, tu app arranca el flujo normal descrito abajo: redirige a /oauth/autorizar con tu propio state y PKCE.

  1. 1

    Tu servidor genera code_verifier y code_challenge

    Con S256, antes de redirigir a nadie.

  2. 2

    Tu servidor redirige al Dueño a /oauth/autorizar

    Con code_challenge, state y los demás parámetros en la URL.

  3. 3
    El Dueño inicia sesión en Wink POS y acepta los permisos

    Todo esto pasa en el navegador del Dueño, nunca en tu servidor.

  4. 4

    Wink POS redirige de vuelta a tu redirect_uri

    Con code y state en la URL.

  5. 5

    Tu servidor canjea el code en POST /oauth/token

    Con code y code_verifier, antes de que pasen 60 segundos.

  6. 6
    Wink POS responde con los tokens

    access_token (1 hora) y refresh_token (30 días).

  7. 7

    Tu servidor usa el access_token en /public/v1

    En cada llamada a la API pública, para ese comercio.

  8. 8
    Antes de que venza, tu servidor rota el refresh

    Guarda siempre el refresh_token nuevo: el anterior deja de servir.

Paso a paso, en Node (crypto y fetch, sin librerías)

Sección titulada «Paso a paso, en Node (crypto y fetch, sin librerías)»
import crypto from 'node:crypto';
// 1. Verifier y challenge S256. El verifier se guarda en tu servidor (sesión o caché de corta
// vida) hasta que el callback llegue: nunca viaja al navegador del Dueño.
const verifier = crypto.randomBytes(48).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
// 2. Arma la URL de autorización y redirige al Dueño ahí (no hay SDK: es una URL).
const authorizeUrl = new URL('https://app.wink-pos.com/oauth/autorizar');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', process.env.WINK_CLIENT_ID);
authorizeUrl.searchParams.set(
'redirect_uri',
'https://mi-app.example.com/callback',
);
authorizeUrl.searchParams.set('scope', 'catalog.view sales.create');
authorizeUrl.searchParams.set('state', crypto.randomUUID());
authorizeUrl.searchParams.set('code_challenge', challenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
// res.redirect(authorizeUrl.toString())
// 3. En tu endpoint de callback (?code=...&state=...): compara `state` con el que guardaste y
// canjea el code por tokens.
async function exchangeCode(code, verifier) {
const res = await fetch('https://api.wink-pos.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.WINK_CLIENT_ID,
client_secret: process.env.WINK_CLIENT_SECRET,
code,
redirect_uri: 'https://mi-app.example.com/callback',
code_verifier: verifier,
}),
});
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
return res.json();
// { access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope }
}
// 4. Guarda los tokens cifrados, asociados a la instalación (un comercio puede tener varias apps
// y una app puede estar instalada en varios comercios: guarda siempre por par app+comercio).
async function saveTokens(businessId, tokens) {
// encrypt(tokens.access_token), encrypt(tokens.refresh_token), ...
}
// 5. Renueva antes de que venza (1 h). El refresh es rotativo: guarda siempre el que te devuelve
// la respuesta, nunca reutilices uno viejo.
async function refresh(refreshToken) {
const res = await fetch('https://api.wink-pos.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.WINK_CLIENT_ID,
client_secret: process.env.WINK_CLIENT_SECRET,
refresh_token: refreshToken,
}),
});
if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
return res.json(); // nuevo access_token + nuevo refresh_token: guarda los dos
}
// 6. Revoca un token concreto cuando ya no lo necesitas. OJO: esto NO desinstala la app.
// Para desinstalar desde tu propia app usa DELETE /public/v1/installation (sección de abajo).
async function revoke(token) {
await fetch('https://api.wink-pos.com/oauth/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.WINK_CLIENT_ID,
client_secret: process.env.WINK_CLIENT_SECRET,
token,
}),
});
}

Cuando el comercio desvincula Wink POS desde tu propia pantalla (por ejemplo, «Desconectar Wink POS»), llama a DELETE /public/v1/installation con el access_token de esa instalación. Es el equivalente a que el Dueño pulse «Desinstalar» en Ajustes → Integraciones: no hay nada más que hacer de tu lado.

Ventana de terminal
curl -X DELETE https://api.wink-pos.com/public/v1/installation \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 204 No Content

Qué pasa al recibir la llamada:

  • La instalación queda desinstalada: deja de aparecer como «Activa» en Ajustes → Integraciones.
  • Se revocan todos sus tokens (access y refresh). Para volver a conectar, el Dueño tiene que repetir el consentimiento.
  • Se envía el webhook app.uninstalled a tus suscripciones y después se borran, igual que cuando desinstala el Dueño.
  • La bitácora del comercio registra que la desinstaló la app (no una persona).

No pide ningún permiso (scope), igual que GET /public/v1/business.

Respuesta Cuándo
204 La instalación se desinstaló.
401 invalid_token Token ausente, vencido o ya revocado. Si repites la llamada tras un 204, el token ya no sirve y da 401.
409 private_app_not_uninstallable Usaste la llave de una app privada (wpa_…): no tiene instalación. El Dueño la revoca en Ajustes → Integraciones.

DELETE /public/v1/installation o POST /oauth/revoke

Sección titulada «DELETE /public/v1/installation o POST /oauth/revoke»
DELETE /public/v1/installation POST /oauth/revoke
Autenticación Authorization: Bearer con el access token client_id + client_secret con el token a revocar
Revoca Todos los tokens de la instalación El token indicado y su familia (access + refresh)
Estado en Ajustes → Integraciones Pasa a desinstalada Sigue «Activa»
Webhook app.uninstalled Sí No

Usa /oauth/revoke solo para invalidar un token (por ejemplo, tras una sospecha de filtración) sin dejar la integración. Para desvincular al comercio, usa siempre DELETE /public/v1/installation.

Causa Qué pasa
redirect_uri no coincide exacto con el registrado No redirige: Wink POS muestra el error directamente, porque para entonces el redirect no se validó
client_secret incorrecto o incompleto invalid_client al canjear el código
El code venció (más de 60 segundos) invalid_grant
El code ya se usó invalid_grant, y además revoca los tokens que se emitieron la primera vez con ese código
PKCE plain No soportado: el esquema solo acepta code_challenge_method=S256
Refresh token reusado fuera de su rotación Se revoca toda la cadena de tokens de esa instalación (igual que una sesión de persona reutilizada)

Tres piezas de tu lado, nada más (no hay SDK oficial: son una URL, una ruta y un lugar donde guardar datos).

a. El botón Conectar. Arma la URL de /oauth/autorizar (paso 2 del código de arriba) y redirige ahí. Genera state nuevo en cada intento y guárdalo en la sesión del Dueño junto con el code_verifier.

b. La ruta de callback en tu servidor (la que registraste como redirect_uri, por ejemplo https://tu-app.com/wink-pos/callback). Recibe uno de estos dos casos por query string:

  • ?code=...&state=... — éxito. Antes de canjear nada, compara state con el que guardaste: si no coincide, corta ahí (protección CSRF: alguien pudo estar intentando colarte un code suyo). Si coincide, canjea el code en /oauth/token — tienes 60 segundos antes de que venza.
  • ?error=...&error_description=...&state=... — el Dueño no completó el flujo. El caso más común es error=access_denied (le dio “Cancelar” en la pantalla de consentimiento): no es una falla tuya, muéstrale una pantalla para reintentar, no un error genérico.

c. Dónde guardar los tokens. Guárdalos cifrados en tu base de datos, indexados por instalación (client_id + comercio, nunca solo por comercio: un mismo comercio puede tener varias apps y tu app puede estar instalada en varios comercios). Para saber qué comercio acaba de conectarse, llama a GET /public/v1/business con el access_token recién recibido — te devuelve el comercio dueño de esa instalación.

Ejemplo completo en Node/Express de las dos rutas (conectar + callback):

import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.get('/wink-pos/conectar', (req, res) => {
const verifier = crypto.randomBytes(48).toString('base64url');
const state = crypto.randomUUID();
// Guarda ambos en la sesión del Dueño hasta que vuelva por el callback.
req.session.oauth = { verifier, state };
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
const url = new URL('https://app.wink-pos.com/oauth/autorizar');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.WINK_CLIENT_ID);
url.searchParams.set('redirect_uri', process.env.WINK_REDIRECT_URI);
url.searchParams.set('scope', 'catalog.view sales.create');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());
});
app.get('/wink-pos/callback', async (req, res) => {
const { code, state, error, error_description: description } = req.query;
if (error) {
// error === 'access_denied' cuando el Dueño cancela: no es un bug tuyo.
return res.render('wink-pos-error', { error, description });
}
const saved = req.session.oauth;
if (!saved || state !== saved.state) {
return res
.status(400)
.send('state no coincide: posible CSRF, reintenta la conexión');
}
const tokenRes = await fetch('https://api.wink-pos.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.WINK_CLIENT_ID,
client_secret: process.env.WINK_CLIENT_SECRET,
code: String(code),
redirect_uri: process.env.WINK_REDIRECT_URI,
code_verifier: saved.verifier,
}),
});
if (!tokenRes.ok) return res.status(502).send('no se pudo canjear el code');
const tokens = await tokenRes.json();
// Con el access_token recién recibido, averigua qué comercio se conectó.
const businessRes = await fetch(
'https://api.wink-pos.com/public/v1/business',
{
headers: { Authorization: `Bearer ${tokens.access_token}` },
},
);
const business = await businessRes.json();
await saveEncryptedTokens(business.id, tokens); // tu función, nunca en texto plano
delete req.session.oauth;
res.redirect('/conectado');
});

Las redirect_uri deben coincidir exactas con lo registrado (ver Pide tu alta): mismo esquema (https://), mismo dominio, misma ruta, sin barra final de más si no la registraste con ella (https://tu-app.com/callback y https://tu-app.com/callback/ son distintas).

Para probar el flujo completo sin escribir nada:

  1. En tu colección o request, pestaña Authorization → tipo OAuth 2.0.
  2. Configuración del token: Grant Type → Authorization Code (With PKCE), Callback URL → activa Authorize using browser (Postman abre tu navegador para el login del Dueño en vez de capturar el callback con su propio servidor).
  3. Auth URL: https://app.wink-pos.com/oauth/autorizar. Access Token URL: https://api.wink-pos.com/oauth/token. Client ID y Client Secret: los de tu app. Scope: los permisos que vas a probar. Code Challenge Method: S256.
  4. Client Authentication: Send as Basic Auth Header o Send client credentials in body — Wink POS acepta ambos, pero usa el mismo método que vas a usar en tu servidor para probar lo real.
  5. Registra https://oauth.pstmn.io/v1/browser-callback como una de las redirect_uri válidas de tu app (es el callback que usa el modo “Authorize using browser” de Postman) — sin esto, Postman completa el login pero el redirect falla.
  6. Get New Access Token: inicia sesión como el Dueño del comercio de pruebas y acepta los permisos. Postman canjea el code solo y te deja el access_token listo para usar en el resto de la colección.

Error típico: pegar el client_secret incompleto (se corta un carácter al copiarlo) da invalid_client al pedir el token, no un error de formato — si te pasa, vuelve a copiar el secreto completo desde donde lo guardaste al crear la app (se muestra una sola vez).

Qué scope pedir para cada acción está en Permisos (scopes). Pide solo lo que tu app usa: la pantalla de consentimiento se lo muestra al Dueño agrupado por módulo, y pedir de más retrasa tu revisión.