Express

La estrategia de Passport, el callback y el token ligado a la petición.

bash
npm install @andco/sdk @andco/passport passport express-session

Si tu aplicación ya usa Passport, @andco/passport te da la estrategia y no cambias nada más de tu flujo. Si no lo usa, la sección final muestra la integración directa.

Con Passport

src/andco.tsts
import { createAndcoInstanceForServer } from "@andco/sdk/server";

export const andco = createAndcoInstanceForServer({
  clientId: process.env.ANDCO_CLIENT_ID!,
  clientSecret: process.env.ANDCO_CLIENT_SECRET!,
});

La estrategia se registra de forma síncrona y sin I/O: la descripción que el servidor de autorización publica de sí mismo viene incorporada en el SDK, leída del servidor al construirlo.

src/auth.tsts
import { AndCoStrategy } from "@andco/passport";
import passport from "passport";
import { andco } from "./andco.js";

passport.use(
  "andco",
  AndCoStrategy.create(
    {
      client: andco,
      callbackURL: new URL("/auth/callback", process.env.APP_ORIGIN),
      scope: ["email", "profile"],
    },
    (tokens, done) => {
      const rest = andco.with(tokens.access_token).rest;
      void rest.http.GET("/userinfo").then(({ data, error }) => {
        if (error) return done(error);
        done(null, { ...data, andco: { accessToken: tokens.access_token, refreshToken: tokens.refresh_token } });
      });
    },
  ),
);

passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((serialized, done) => done(null, serialized as Express.User));

Se registra una vez, al arrancar. Como es asíncrona, el archivo tiene que ser un módulo ES con await de nivel superior, o envolverse en el arranque de tu servidor.

Las rutas

src/index.tsts
import express from "express";
import session from "express-session";
import passport from "passport";

const app = express();

// If a proxy terminates TLS, this is what lets the cookie be marked secure.
app.set("trust proxy", 1);
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());

app.get("/auth/andco", passport.authenticate("andco", { scope: ["email", "profile"] }));

app.get(
  "/auth/callback",
  passport.authenticate("andco", { failureRedirect: "/?error=andco" }),
  (_req, res) => res.redirect("/"),
);

Ligar la credencial a la petición

src/middleware.tsts
import type { NextFunction, Request, Response } from "express";
import { andco } from "./andco.js";

export function withAndco(req: Request, res: Response, next: NextFunction) {
  const user = req.user as { andco?: { accessToken: string } } | undefined;
  if (!user?.andco) return res.status(401).json({ error: "unauthenticated" });
  req.andco = andco.with(user.andco.accessToken);
  next();
}
ts
app.get("/accounts", withAndco, async (req, res) => {
  const { data, error } = await req.andco.rest.http.GET("/accounts");
  if (error) return res.status(error.status ?? 500).json({ error: error.code });
  res.json(data);
});

with() devuelve un cliente nuevo y no toca la instancia compartida. Es lo que impide que la credencial de una petición aparezca en otra.

Sin Passport

ts
app.get("/auth/andco", async (req, res) => {
  const { data: request, error } = await andco.oauth.createAuthorizationRequest({
    redirectTo: `${process.env.APP_ORIGIN}/auth/callback`,
    scopes: ["openid", "email", "profile"],
  });
  if (error) return res.status(500).json({ error: error.code });

  req.session.andcoTransaction = { ...request, authorizationUrl: request.authorizationUrl.href };
  res.redirect(request.authorizationUrl.href);
});

app.get("/auth/callback", async (req, res) => {
  const stored = req.session.andcoTransaction;
  if (!stored) return res.status(400).json({ error: "missing_transaction" });

  const { data: session, error } = await andco.oauth.exchangeCallback({
    callbackUrl: new URL(req.originalUrl, process.env.APP_ORIGIN),
    request: { ...stored, authorizationUrl: new URL(stored.authorizationUrl) },
  });
  delete req.session.andcoTransaction;
  if (error) return res.status(400).json({ error: error.code });

  req.session.andco = session;
  res.redirect("/");
});

Webhooks

La firma cubre los bytes crudos del cuerpo, así que la ruta del webhook necesita el cuerpo sin parsear:

ts
import { ANDCO_WEBHOOK_HEADERS, andCoWebhookVerify } from "@andco/protocol";
import express from "express";

app.post("/webhooks/andco", express.raw({ type: "application/json" }), async (req, res) => {
  const trusted = await andCoWebhookVerify({
    body: (req.body as Buffer).toString("utf8"),
    signature: req.header(ANDCO_WEBHOOK_HEADERS.signature),
    eventId: req.header(ANDCO_WEBHOOK_HEADERS.eventId),
    timestamp: req.header(ANDCO_WEBHOOK_HEADERS.timestamp),
    secret: process.env.ANDCO_WEBHOOK_SECRET!,
  });
  if (!trusted) return res.status(400).json({ error: "invalid_signature" });

  await handleEvent(JSON.parse((req.body as Buffer).toString("utf8")));
  res.status(204).end();
});

express.json() global rompe esto sin avisar: parsea el cuerpo antes de que lo veas y lo firmado deja de existir.