Skip to content

@last1id/sdk-nextjs

Next.js App Router companion to @last1id/sdk.

Drops in a /api/auth/last1 catch-all route, manages an encrypted session cookie, and provides helpers for reading the session inside Server Components and Route Handlers — including automatic refresh-token rotation that persists back to the cookie on the next response.

npm install @last1id/sdk-nextjs @last1id/sdk

This package adds no runtime dependencies beyond the base SDK. The optional peer on next is only consulted when you call getLast1Session() from a Server Component.

Quick start

1. Generate a session secret

openssl rand -base64 48

Add it to your environment:

LAST1_ISSUER_URL=https://auth.last1.id
LAST1_CLIENT_ID=acme   # Copy Client ID (UUID) or App ID slug
LAST1_CLIENT_SECRET=...
LAST1_REDIRECT_URI=https://app.acme.example/api/auth/last1/callback
LAST1_SESSION_SECRET=<openssl output>

2. Mount the catch-all route

Create app/api/auth/last1/[...last1]/route.ts:

import { createLast1Handler } from "@last1id/sdk-nextjs";

// Edge runtime works too — the handler uses only Web Crypto + fetch.
export const runtime = "nodejs";

const handler = createLast1Handler({
  issuerUrl: process.env.LAST1_ISSUER_URL!,
  clientId: process.env.LAST1_CLIENT_ID!,
  clientSecret: process.env.LAST1_CLIENT_SECRET!,
  redirectUri: process.env.LAST1_REDIRECT_URI!,
  sessionSecret: process.env.LAST1_SESSION_SECRET!,
  scopes: ["openid", "profile", "credentials:read"],
  loginSuccessRedirect: "next",
  logoutRedirect: "/",
  onLoginSuccess: async ({ identityId, claims, tokens }) => {
    // Partner-side DB upsert keyed on identityId. Throwing here
    // fails the login closed — no session cookie is written.
    await db.users.upsert({ last1IdentityId: identityId, email: claims?.email });
  },
});

export const { GET, POST } = handler;

This auto-wires four endpoints:

Method Path Behavior
GET /api/auth/last1/login Builds PKCE, redirects to ${issuerUrl}/oauth/authorize. Accepts ?next=/path (validated same-origin).
GET /api/auth/last1/callback Exchanges the code, fires onLoginSuccess, sets the session cookie, redirects to next or loginSuccessRedirect.
POST /api/auth/last1/logout Fires onLogout, clears the cookie, redirects to logoutRedirect.
GET /api/auth/last1/me Returns { session: { identityId, scope, accessTokenExpiresAt, issuedAt } } or 401. Tokens are NEVER included in the response body.

Any other path under the catch-all returns 404.

3. Add a sign-in button

// app/page.tsx
export default async function Home() {
  return (
    <a href="/api/auth/last1/login?next=/dashboard">
      Sign in with Last1 ID
    </a>
  );
}

4. Read the session in a Server Component

import { getLast1Session } from "@last1id/sdk-nextjs";
import { redirect } from "next/navigation";

export default async function Dashboard() {
  const session = await getLast1Session({
    sessionSecret: process.env.LAST1_SESSION_SECRET!,
  });
  if (!session) redirect("/api/auth/last1/login?next=/dashboard");
  return <h1>Hi, {session.identityId}</h1>;
}

getLast1Session returns null on:

  • No session cookie present.
  • A torn / tampered / rotated-secret cookie. (We intentionally do not distinguish these — the caller always treats null as "show sign-in".)

The returned Last1Session includes accessToken and refreshToken so your Server Component can pass them onward, but you almost always want the helper below instead.

5. Call partner-read APIs from a Route Handler

// app/api/me/credentials/route.ts
import { withLast1Session } from "@last1id/sdk-nextjs";

export const GET = withLast1Session(
  {
    issuerUrl: process.env.LAST1_ISSUER_URL!,
    clientId: process.env.LAST1_CLIENT_ID!,
    clientSecret: process.env.LAST1_CLIENT_SECRET!,
    sessionSecret: process.env.LAST1_SESSION_SECRET!,
  },
  async (req, { client, session }) => {
    const credentials = await client.listCredentials();
    return Response.json({ identityId: session.identityId, credentials });
  },
);

withLast1Session arranges for the rotated tokens (if any) to be written back into the response Set-Cookie header automatically. The next request from the same browser uses the new pair without partner code involvement.

If the refresh chain is dead (Last1AuthError on rotation — user revoked consent, refresh token was reused, etc), the wrapper:

  • Returns the configured unauthorizedResponse (defaults to a 401 JSON).
  • Sends a Set-Cookie that clears the session cookie.

The next page load will therefore see getLast1Session() === null and prompt the user to re-link cleanly.

Cross-app revoke webhooks

When the user revokes consent on last1.id, the platform calls your configured webhook URL. Verify it with verifyWebhookSignature from @last1id/sdk and then call clearLast1SessionCookie if you want to preemptively log the user out on next request:

// app/api/webhooks/last1/route.ts
import { verifyWebhookSignature } from "@last1id/sdk";

export async function POST(req: Request) {
  const raw = await req.text();
  try {
    await verifyWebhookSignature(
      process.env.LAST1_WEBHOOK_SECRET!,
      req.headers.get("x-last1-signature") ?? undefined,
      raw,
    );
  } catch {
    return new Response(null, { status: 204 }); // fail closed, no info leak
  }
  const event = JSON.parse(raw);
  // event.type === "consent.revoked" or "app_link.revoked"
  await db.users.update(event.data.identity_id, { last1Linked: false });
  return new Response(null, { status: 204 });
}

The session cookie itself is encrypted with sessionSecret and lives in the browser; it expires naturally when the refresh token is invalidated. Your DB unlink is what actually disconnects subsequent requests.

Property Default
Name last1_session (PKCE: last1_session_pkce)
HttpOnly Always
Secure true when NODE_ENV === "production"
SameSite Lax (enough for the OAuth redirect)
Path /
Max-Age 30 days (session), 600s (PKCE)
Encryption AES-256-GCM via Web Crypto
Key derivation HKDF-SHA256 with per-purpose info label

The session cookie payload is JSON containing access_token, refresh_token, scope, and timestamps — encrypted in place. Total cookie size for typical scopes is well under the 4KB browser limit.

Rotating LAST1_SESSION_SECRET immediately invalidates every existing session. Plan accordingly: change the secret only when you intend a global sign-out (e.g. after a security incident).

Running on Edge runtime

Everything in this package is Web-Crypto + standard Request/Response, so you can flip the route to Edge:

export const runtime = "edge";

getLast1Session dynamically imports next/headers; that import resolves correctly on both runtimes in Next.js 14+.

Pages Router

This package targets the App Router. For Pages Router, build a thin adapter that converts NextApiRequest to a Request and pipes the Response back out — or migrate the auth endpoints to the App Router even if the rest of the app stays on Pages.

License

MIT