@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.
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¶
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
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:
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