@last1id/sdk-nextjs
v0.1.0
Published
Next.js App Router companion for @last1id/sdk: drop-in /api/auth/last1 route handler, encrypted session cookie, and auto-refresh helpers for Server Components and Route Handlers
Maintainers
Readme
@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/sdkThis 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 48Add it to your environment:
LAST1_ISSUER_URL=https://last1.id
LAST1_CLIENT_ID=acme
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
nullas "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-Cookiethat 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.
Cookie security model
| 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
