Sign-in with Human Pass in a Next.js App Router app, using two route handlers and openid-client. Your own session handling stays as it is.
Setup
shell
npm install openid-client.env.local
HP_ISSUER=https://id.sandbox.humanpass.co
HP_CLIENT_ID=hp_test_…
HP_CLIENT_SECRET=hps_…
HP_REDIRECT_URI=http://localhost:3000/auth/callbackRegister http://localhost:3000/auth/callback as the client's redirect URI in the dashboard (in production, your https address).
The Human Pass client
lib/humanpass.ts
import "server-only";
import * as oidc from "openid-client";
let config: Promise<oidc.Configuration> | undefined;
/** Human Pass's settings, discovered once. */
export function humanPass() {
config ??= oidc.discovery(
new URL(process.env.HP_ISSUER!),
process.env.HP_CLIENT_ID!,
{ id_token_signed_response_alg: "ES256" },
oidc.ClientSecretBasic(process.env.HP_CLIENT_SECRET!),
);
return config;
}
/** Where the sign-in's PKCE verifier, state and nonce wait for the callback. */
export const FLOW_COOKIE = "hp_flow";The sign-in route
app/auth/login/route.ts
import * as oidc from "openid-client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { FLOW_COOKIE, humanPass } from "@/lib/humanpass";
export async function GET() {
const config = await humanPass();
const flow = { verifier: oidc.randomPKCECodeVerifier(), state: oidc.randomState(), nonce: oidc.randomNonce() };
(await cookies()).set(FLOW_COOKIE, JSON.stringify(flow), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax", // sent on the redirect back from Human Pass
maxAge: 15 * 60,
path: "/auth",
});
const url = oidc.buildAuthorizationUrl(config, {
redirect_uri: process.env.HP_REDIRECT_URI!,
scope: "openid personhood",
code_challenge: await oidc.calculatePKCECodeChallenge(flow.verifier),
code_challenge_method: "S256",
state: flow.state,
nonce: flow.nonce,
});
redirect(url.href);
}The callback route
app/auth/callback/route.ts
import * as oidc from "openid-client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { NextRequest } from "next/server";
import { FLOW_COOKIE, humanPass } from "@/lib/humanpass";
import { accountFor, startSession } from "@/lib/accounts"; // your own code
export async function GET(request: NextRequest) {
const jar = await cookies();
const flow = JSON.parse(jar.get(FLOW_COOKIE)?.value ?? "null");
jar.delete({ name: FLOW_COOKIE, path: "/auth" });
// Cancelled, or couldn't be verified: Human Pass doesn't say which.
if (!flow || request.nextUrl.searchParams.has("error")) redirect("/?signin=failed");
// Rebuild the callback address from the registered one: behind a proxy, request.url can differ.
const callbackUrl = new URL(request.nextUrl.search, process.env.HP_REDIRECT_URI!);
const tokens = await oidc.authorizationCodeGrant(await humanPass(), callbackUrl, {
pkceCodeVerifier: flow.verifier,
expectedState: flow.state,
expectedNonce: flow.nonce,
idTokenExpected: true,
});
const claims = tokens.claims()!;
if (claims.verified_human !== true) redirect("/?signin=failed");
const account = await accountFor(claims.sub, claims.previous_sub as string | undefined);
await startSession(account.id);
redirect("/");
}accountFor is the "one person, one account" lookup from the quickstart; startSession is however your app signs people in (a session cookie, or your auth library's session).
The button
app/page.tsx
export default function Home() {
return (
<a href="/auth/login" className="button">
Continue with Human Pass
</a>
);
}