npm package: @lintted/accounts-sdk
Description: SDK para el servicio OAuth de accounts.lintted.com
Latest version: 1.0.6

# @lintted/accounts-sdk

SDK oficial de JavaScript/TypeScript para el servicio de autenticación OAuth2 de Lintted. Proporciona una implementación completa del flujo **Authorization Code + PKCE** con protección CSRF integrada, almacenamiento de tokens flexible y un cliente API completo para el backend.

---

## Tabla de Contenidos

- [Instalación](#instalación)
- [Inicio Rápido](#inicio-rápido)
- [Conceptos Clave](#conceptos-clave)
- [LinttedAuth — Cliente Frontend](#linttedauth--cliente-frontend)
  - [Opciones del Constructor](#opciones-del-constructor)
  - [Métodos](#métodos)
- [AccountsBackend — Cliente API](#accountsbackend--cliente-api)
  - [Opciones del Constructor](#opciones-del-constructor-1)
  - [Métodos](#métodos-1)
- [Almacenamiento de Tokens](#almacenamiento-de-tokens)
  - [sessionStorage (por defecto)](#sessionstorage-por-defecto)
  - [Almacenamiento en Cookies](#almacenamiento-en-cookies)
  - [Almacenamiento Personalizado](#almacenamiento-personalizado)
- [Scopes de OAuth](#scopes-de-oauth)
- [Tipos de TypeScript](#tipos-de-typescript)
- [Ejemplos de Integración Completos](#ejemplos-de-integración-completos)
  - [React SPA](#react-spa)
  - [Next.js](#nextjs)
- [Manejo de Errores](#manejo-de-errores)
- [Notas de Seguridad](#notas-de-seguridad)
- [Licencia](#licencia)

---

## Instalación

```bash
npm install @lintted/accounts-sdk
# o
yarn add @lintted/accounts-sdk
# o
pnpm add @lintted/accounts-sdk
```

---

## Inicio Rápido

```typescript
import { LinttedAuth } from "@lintted/accounts-sdk";

// 1. Inicializar el cliente
const auth = new LinttedAuth({
  client_id: "tu-client-id",
  redirect_uri: "https://tuapp.com/callback",
  scopes: ["id", "email", "username"],
});

// 2. Redirigir al usuario a la página de login de Lintted
await auth.login();

// 3. En la página de callback, intercambiar el código por tokens
const params = new URLSearchParams(window.location.search);
const tokens = await auth.handle_callback(
  params.get("code")!,
  params.get("state")!,
);
// tokens: { access_token, refresh_token, unique_device }

// 4. Verificar el estado de autenticación en cualquier parte de la app
if (auth.isLoggedIn()) {
  const accessToken = auth.getAccessToken();
}

// 5. Validar la sesión en el servidor
const session = await auth.backend.validate_session(accessToken, {
  unique_device: auth.getUniqueDevice()!,
  refresh_token: auth.getRefreshToken()!,
});
```

---

## Conceptos Clave

| Concepto          | Descripción                                                                                                      |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| **PKCE**          | Par de code verifier/challenge generado en cada login para prevenir ataques de interceptación de código          |
| **CSRF State**    | UUID aleatorio almacenado en sesión y verificado en el callback para prevenir ataques de falsificación de origen |
| **unique_device** | Identificador opaco de dispositivo devuelto en el login; requerido en todas las llamadas API posteriores         |
| **refresh_token** | Token de larga duración (15 días) usado para renovar access tokens sin necesidad de re-login                     |
| **access_token**  | Token Bearer de corta duración enviado en cada solicitud a la API                                                |

---

## LinttedAuth — Cliente Frontend

```typescript
import { LinttedAuth } from "@lintted/accounts-sdk";
```

### Opciones del Constructor

```typescript
const auth = new LinttedAuth(options: LinttedAuthOptions);
```

| Opción         | Tipo          | Requerido | Por defecto                                                           | Descripción                                           |
| -------------- | ------------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------- |
| `client_id`    | `string`      | ✅        | —                                                                     | Client ID de tu aplicación OAuth                      |
| `redirect_uri` | `string`      | ✅        | —                                                                     | URL de callback registrada en tu aplicación           |
| `scopes`       | `string[]`    | ❌        | `[]`                                                                  | Lista de scopes OAuth a solicitar                     |
| `client_name`  | `string`      | ❌        | —                                                                     | Nombre de la app mostrado en la pantalla de consentimiento |
| `auth_url`     | `string`      | ❌        | `https://accounts.lintted.com/allow/connection`                       | Sobreescribe el endpoint de autorización              |
| `token_url`    | `string`      | ❌        | `https://accounts.lintted.com/api/oauth/authorization/validate/token` | Sobreescribe el endpoint de tokens                    |
| `storage`      | `StorageLike` | ❌        | `sessionStorage`                                                      | Adaptador de almacenamiento personalizado para tokens |

> Se aceptan tanto aliases en camelCase (`clientId`, `redirectUri`) como en snake_case (`client_id`, `redirect_uri`) para todas las opciones.

### Métodos

#### `login(redirect?: boolean): Promise<string>`

Genera un challenge PKCE y un estado CSRF, luego redirige al usuario a la página de autorización de Lintted.

```typescript
// Redirigir automáticamente (por defecto)
await auth.login();

// Obtener la URL sin redirigir (p. ej. para navegación personalizada)
const url = await auth.login(false);
router.navigate(url);
```

#### `handle_callback(code: string, state: string): Promise<TokenResponse>`

Valida el estado CSRF e intercambia el código de autorización por tokens. Los tokens se persisten automáticamente en el almacenamiento.

```typescript
const tokens = await auth.handle_callback(code, state);
// { access_token, refresh_token, unique_device, token_type, expires_in }
```

Lanza `Error("Invalid CSRF state")` si el parámetro `state` no coincide.

#### `getAccessToken(): string | null`

Devuelve el access token almacenado, o `null` si no está autenticado.

#### `getRefreshToken(): string | null`

Devuelve el refresh token almacenado, o `null`.

#### `getUniqueDevice(): string | null`

Devuelve el identificador único de dispositivo almacenado, o `null`.

#### `getStoredTokens(): Record<string, any> | null`

Devuelve todos los tokens almacenados como un objeto `{ access_token, refresh_token, unique_device }`, o `null` si no existe ninguno.

#### `isLoggedIn(): boolean`

Devuelve `true` si hay un access token en el almacenamiento.

#### `hasTokens(): boolean`

Devuelve `true` si existe cualquier token (access, refresh o device ID) en el almacenamiento.

#### `saveTokens(tokens: Record<string, any>): void`

Persiste tokens manualmente en el almacenamiento. Útil al renovar el access token cuando `validate_session` devuelve `renewed: true`.

```typescript
if (result.renewed && result.auth_token) {
  auth.saveTokens({ access_token: result.auth_token });
}
```

#### `clearTokens(): void`

Elimina todos los tokens de Lintted del almacenamiento. Úsalo para implementar el logout en el lado del cliente.

```typescript
auth.clearTokens();
```

#### `backend: AccountsBackend`

Acceso directo a la instancia de `AccountsBackend` asociada a este cliente.

---

## AccountsBackend — Cliente API

La clase `AccountsBackend` es el cliente HTTP de bajo nivel para la API de cuentas de Lintted. Se instancia automáticamente como `auth.backend`, pero también puede usarse de forma independiente en un servidor.

```typescript
import { AccountsBackend } from "@lintted/accounts-sdk";
```

### Opciones del Constructor

```typescript
const backend = new AccountsBackend(options?: AccountsBackendOptions);
```

| Opción      | Tipo           | Por defecto                              | Descripción                                |
| ----------- | -------------- | ---------------------------------------- | ------------------------------------------ |
| `baseUrl`   | `string`       | `https://accounts.lintted.com/api/oauth` | Sobreescribe la URL base de la API         |
| `fetcher`   | `typeof fetch` | `cross-fetch`                            | Implementación personalizada de fetch      |
| `timeoutMs` | `number`       | `10000`                                  | Tiempo máximo de espera de la solicitud (ms) |

### Métodos

#### `validate_authorization(params): Promise<validate_token_response>`

Intercambia un código de autorización por tokens (usado internamente por `handle_callback`).

```typescript
const tokens = await backend.validate_authorization({
  grant_type: "authorization_code",
  code: "auth-code",
  client_id: "tu-client-id",
  redirect_uri: "https://tuapp.com/callback",
  code_verifier: "pkce-verifier",
});
// { access_token, refresh_token, token_type, expires_in, unique_device }
```

#### `validate_session(accessToken, params): Promise<validate_session_response>`

Valida la sesión actual. Si el access token ha expirado, el servidor lo renueva usando el refresh token.

```typescript
const result = await backend.validate_session(accessToken, {
  unique_device: "device-id",
  refresh_token: "refresh-token",
});

if (result.renewed && result.auth_token) {
  // Guardar el nuevo access token
  auth.saveTokens({ access_token: result.auth_token });
}
```

**Campos de la respuesta:**

| Campo        | Tipo      | Descripción                                                   |
| ------------ | --------- | ------------------------------------------------------------- |
| `message`    | `string`  | Estado: `"Token renovado"` o `"Sesión válida"`                |
| `session`    | `object`  | Registro completo de sesión de la base de datos               |
| `renewed`    | `boolean` | `true` si el access token fue renovado                        |
| `auth_token` | `string?` | Nuevo access token — solo presente cuando `renewed` es `true` |

**Errores:**

- `401` — Refresh token inválido o expirado (debe volver a hacer login)
- `404` — Sesión no encontrada

> Nota: Solo el `access_token` se renueva en este endpoint. Si el `refresh_token` expira (tras 15 días), el usuario debe iniciar sesión nuevamente.

#### `get_oauth_user_data(accessToken, params): Promise<oauth_user_data_response>`

Obtiene los campos del perfil del usuario para los scopes solicitados.

```typescript
const user = await backend.get_oauth_user_data(accessToken, {
  scopes: ["id", "email", "username", "first_name"],
  unique_device: "device-id",
  refresh_token: "refresh-token",
});
// { id, email, username, first_name, ... }
```

#### `logout(accessToken, params): Promise<logout_response>`

Invalida la sesión en el servidor.

```typescript
const result = await backend.logout(accessToken, {
  unique_device: "device-id",
  refresh_token: "refresh-token",
});
// { message: "..." }
```

#### `get_permissions(accessToken, params): Promise<get_permissions_response>`

Obtiene los roles y permisos del usuario.

```typescript
const perms = await backend.get_permissions(accessToken, {
  unique_device: "device-id",
  refresh_token: "refresh-token",
});
// { roles: [...], permissions: [...], auth_version: 1 }
```

---

## Almacenamiento de Tokens

### sessionStorage (por defecto)

Los tokens se almacenan en `sessionStorage` por defecto. Se eliminan automáticamente cuando se cierra la pestaña del navegador.

### Almacenamiento en Cookies

Usa `createCookieStorage` para persistir tokens en cookies del navegador con flags de seguridad.

```typescript
import { LinttedAuth, createCookieStorage } from "@lintted/accounts-sdk";

const auth = new LinttedAuth({
  client_id: "tu-client-id",
  redirect_uri: "https://tuapp.com/callback",
  storage: createCookieStorage({
    secure: true,
    sameSite: "Strict",
    cookieOptions: {
      lintted_auth_token: { secure: true },
      lintted_refresh_token: { secure: true },
      lintted_unique_device: { secure: true },
    },
  }),
});
```

Por defecto, sin configurar `cookieOptions`, el SDK guarda:

- `lintted_auth_token`: 1 hora
- `lintted_refresh_token`: 15 días
- `lintted_unique_device`: 15 días

| Opción           | Tipo                          | Por defecto | Descripción                            |
| ---------------- | ----------------------------- | ----------- | -------------------------------------- |
| `secure`         | `boolean`                     | `true`      | Añade el flag `Secure` (solo HTTPS)    |
| `sameSite`       | `"Strict" \| "Lax" \| "None"` | `"Strict"`  | Política SameSite de la cookie         |
| `cookieOptions`  | `Record<string, object>`      | `{}`        | Opciones por cookie, por clave         |

### Almacenamiento Personalizado

Implementa la interfaz `StorageLike` para usar cualquier backend de almacenamiento (IndexedDB, AsyncStorage, en memoria, etc.):

```typescript
import type { StorageLike } from "@lintted/accounts-sdk";

const miAlmacenamiento: StorageLike = {
  getItem(key: string): string | null {
    /* ... */
  },
  setItem(key: string, value: string): void {
    /* ... */
  },
  removeItem(key: string): void {
    /* ... */
  },
};

const auth = new LinttedAuth({
  client_id: "...",
  redirect_uri: "...",
  storage: miAlmacenamiento,
});
```

---

## Scopes de OAuth

Los siguientes scopes pueden solicitarse:

| Scope                 | Descripción                    |
| --------------------- | ------------------------------ |
| `id`                  | Identificador único del usuario |
| `first_name`          | Nombre                         |
| `last_name`           | Apellido                       |
| `username`            | Nombre de usuario / handle     |
| `email`               | Correo electrónico             |
| `gender`              | Género                         |
| `profile_picture_url` | URL del avatar                 |
| `date_of_birth`       | Fecha de nacimiento            |
| `country`             | País                           |
| `phone_number`        | Número de teléfono             |
| `state`               | Estado / provincia             |
| `city`                | Ciudad                         |
| `postal_code`         | Código postal                  |
| `address_line_1`      | Dirección línea 1              |
| `address_line_2`      | Dirección línea 2              |

```typescript
import { OAUTH_SCOPES } from "@lintted/accounts-sdk/types";
// Todos los valores de scope disponibles como una tupla readonly
```

---

## Tipos de TypeScript

Todos los tipos se exportan desde el paquete:

```typescript
import type {
  LinttedAuthOptions,
  StorageLike,
  AccountsBackendOptions,
  validate_token_params,
  validate_token_response,
  validate_session_params,
  validate_session_response,
  oauth_user_data_params,
  oauth_user_data_response,
  logout_params,
  logout_response,
  get_permissions_params,
  get_permissions_response,
  OAuthScope,
} from "@lintted/accounts-sdk";
```

---

## Ejemplos de Integración Completos

### React SPA

```typescript
// auth.ts — instancia singleton
import { LinttedAuth } from "@lintted/accounts-sdk";

export const auth = new LinttedAuth({
  client_id: import.meta.env.VITE_CLIENT_ID,
  redirect_uri: `${window.location.origin}/callback`,
  scopes: ["id", "email", "username", "first_name", "last_name"],
});
```

```tsx
// LoginButton.tsx
import { auth } from "./auth";

export function LoginButton() {
  return (
    <button onClick={() => auth.login()}>Iniciar sesión con Lintted</button>
  );
}
```

```tsx
// CallbackPage.tsx
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { auth } from "./auth";

export function CallbackPage() {
  const navigate = useNavigate();

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const code = params.get("code");
    const state = params.get("state");

    if (code && state) {
      auth
        .handle_callback(code, state)
        .then(() => navigate("/dashboard"))
        .catch((err) => console.error("Error de autenticación:", err));
    }
  }, []);

  return <p>Autenticando...</p>;
}
```

```tsx
// Dashboard.tsx
import { useEffect, useState } from "react";
import { auth } from "./auth";

export function Dashboard() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const tokens = auth.getStoredTokens();
    if (!tokens) return;

    auth.backend
      .get_oauth_user_data(tokens.access_token, {
        scopes: ["id", "email", "username"],
        unique_device: tokens.unique_device,
        refresh_token: tokens.refresh_token,
      })
      .then(setUser);
  }, []);

  const handleLogout = async () => {
    const tokens = auth.getStoredTokens();
    if (tokens) {
      await auth.backend.logout(tokens.access_token, {
        unique_device: tokens.unique_device,
        refresh_token: tokens.refresh_token,
      });
    }
    auth.clearTokens();
    window.location.href = "/";
  };

  return (
    <div>
      <pre>{JSON.stringify(user, null, 2)}</pre>
      <button onClick={handleLogout}>Cerrar sesión</button>
    </div>
  );
}
```

### Next.js

```typescript
// lib/auth.ts — cliente backend para el servidor
import { AccountsBackend } from "@lintted/accounts-sdk";

export const backend = new AccountsBackend();

export async function validateRequest(
  accessToken: string,
  uniqueDevice: string,
  refreshToken: string,
) {
  try {
    return await backend.validate_session(accessToken, {
      unique_device: uniqueDevice,
      refresh_token: refreshToken,
    });
  } catch {
    return null;
  }
}
```

```typescript
// app/api/me/route.ts
import { validateRequest } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const accessToken = req.headers.get("authorization")?.split(" ")[1];
  const uniqueDevice = req.headers.get("x-unique-device");
  const refreshToken = req.headers.get("x-refresh-token");

  if (!accessToken || !uniqueDevice || !refreshToken) {
    return NextResponse.json({ error: "No autorizado" }, { status: 401 });
  }

  const session = await validateRequest(
    accessToken,
    uniqueDevice,
    refreshToken,
  );
  if (!session) {
    return NextResponse.json({ error: "Sesión inválida" }, { status: 401 });
  }

  return NextResponse.json(session);
}
```

---

## Manejo de Errores

`AccountsBackend` lanza `AccountsBackendError` en respuestas no 2xx:

```typescript
import { AccountsBackendError } from "@lintted/accounts-sdk";

try {
  await auth.backend.validate_session(accessToken, {
    unique_device,
    refresh_token,
  });
} catch (err) {
  if (err instanceof AccountsBackendError) {
    console.error(`HTTP ${err.status}: ${err.body}`);

    if (err.status === 401) {
      // Refresh token expirado — forzar re-login
      auth.clearTokens();
      await auth.login();
    }
  }
}
```

`LinttedAuth` lanza objetos `Error` estándar:

| Mensaje                                   | Causa                                                      |
| ----------------------------------------- | ---------------------------------------------------------- |
| `"LinttedAuth: client_id is required"`    | Falta `client_id` en las opciones del constructor          |
| `"LinttedAuth: redirect_uri is required"` | Falta `redirect_uri` en las opciones del constructor       |
| `"Invalid CSRF state"`                    | El state no coincide en el callback — posible ataque CSRF  |

---

## Notas de Seguridad

- **PKCE** (`S256`) siempre está activado — el code verifier nunca sale del cliente.
- El **estado CSRF** es un UUID criptográfico generado con `crypto.randomUUID()` y validado en cada callback.
- El `unique_device` y el `refresh_token` se transmiten en cabeceras HTTP (`x-unique-device`, `x-refresh-token`) — no como parámetros de URL — para evitar que queden expuestos en los logs del servidor.
- Al usar `createCookieStorage`, las cookies se configuran con `Secure` y `SameSite=Strict` por defecto.
- Los refresh tokens expiran tras **15 días**. Un refresh token expirado requiere un re-login completo.

---

## Licencia

MIT © Lintted

## License

MIT © Lintted