Next.js (App Router)
📋 Confidential client registration required
The code in this guide sends client_secret to /oauth/token (kept securely on the server). When you register the RP in the Developer Console, choose Client type: Confidential. For a Public/SPA client, follow the Public Clients guide and never put client_secret in client-side code.
SDK quick start (Client SPA)
If you have an SPA page that starts and completes PKCE directly in the browser, use the npm package. PKCE, state, token rotation, and LogiAuthError (8 codes) mapping are all built in.
npm install @logi-auth/browser// app/auth-client.ts
import { LogiAuth } from '@logi-auth/browser';
export const auth = new LogiAuth({
clientId: process.env.NEXT_PUBLIC_LOGI_CLIENT_ID!,
redirectUri: typeof window !== 'undefined'
? `${window.location.origin}/auth/callback`
: '',
});Server-side (Route Handlers / Pages API): logi serves an OIDC discovery document at /.well-known/openid-configuration, so a standard OIDC client (oauth4webapi, openid-client, next-auth, auth.js) is configured automatically with a single line — issuer: 'https://api.1pass.dev'. If you prefer not to add a dependency, use the hand-rolled route handler example below — it behaves identically.
Login button → full Authorization Code + PKCE flow.
1. Environment variables
# .env.local
LOGI_API_URL=https://api.1pass.dev
LOGI_CLIENT_ID=logi_...
LOGI_CLIENT_SECRET=logi_secret_...
LOGI_REDIRECT_URI=http://localhost:3000/api/auth/callback2. Login start route
// app/api/auth/login/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
function base64url(buf: ArrayBuffer) {
return Buffer.from(buf).toString("base64url");
}
export async function GET() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const jar = await cookies();
jar.set("logi_pkce", verifier, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 });
jar.set("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 });
const url = new URL(`${process.env.LOGI_API_URL}/oauth/authorize`);
url.searchParams.set("client_id", process.env.LOGI_CLIENT_ID!);
url.searchParams.set("redirect_uri", process.env.LOGI_REDIRECT_URI!);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid profile:basic email");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
return NextResponse.redirect(url);
}3. Callback route
// app/api/auth/callback/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const u = new URL(req.url);
const code = u.searchParams.get("code");
const state = u.searchParams.get("state");
const jar = await cookies();
if (!code || !state || state !== jar.get("logi_state")?.value) {
return NextResponse.json({ error: "state mismatch" }, { status: 400 });
}
const verifier = jar.get("logi_pkce")?.value!;
const body = new URLSearchParams({
grant_type: "authorization_code",
code, redirect_uri: process.env.LOGI_REDIRECT_URI!,
code_verifier: verifier,
client_id: process.env.LOGI_CLIENT_ID!,
client_secret: process.env.LOGI_CLIENT_SECRET!,
});
const res = await fetch(`${process.env.LOGI_API_URL}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const tokens = await res.json();
// tokens.refresh_token → httpOnly cookie (rotation covered automatically)
jar.set("logi_rt", tokens.refresh_token, { httpOnly: true, secure: true, sameSite: "strict" });
jar.delete("logi_pkce");
jar.delete("logi_state");
return NextResponse.redirect(new URL("/", req.url));
}4. Login button — do not use next/link
Trigger the OAuth start route (/api/auth/login) with a native <a href>. The next/link (<Link>) component lets the client-side router intercept navigation and handle it with fetch(); when following a same-origin → api.1pass.dev cross-origin 302, that fails silently on CORS (clicking does nothing).
// ✅ works
<a href="/api/auth/login">Sign in with 1pass</a>
// ❌ clicking does nothing
import Link from "next/link";
<Link href="/api/auth/login">Sign in with 1pass</Link>Detailed diagnosis and fix: see Scenario 8 in Troubleshooting.
5. Calling userinfo from a protected API
const me = await fetch(`${process.env.LOGI_API_URL}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${access_token}` },
}).then(r => r.json());If you prefer stateless JWT verification, see verifying JWKS with jose.
Health check endpoint
This is the Next.js (App Router) handler for the RP active health check protocol. Every hour the 1pass IdP sends an HMAC-signed GET to /.well-known/logi-rp-health; when the RP echoes its registered client_id, the console shows 🟢.
1. Env variables
# .env.local (or Vercel/Render env)
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<copy from the console>2. Route handler
// app/.well-known/logi-rp-health/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
const MAX_SKEW = 300; // seconds
export const dynamic = "force-dynamic"; // no caching — must re-sign every request
export const runtime = "nodejs"; // node:crypto required
export async function GET(req: NextRequest) {
const ts = Number(req.headers.get("x-logi-timestamp"));
const cid = req.headers.get("x-logi-client-id") ?? "";
const sig = req.headers.get("x-logi-signature") ?? "";
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_SKEW) {
return new NextResponse(null, { status: 401 });
}
if (cid !== process.env.LOGI_CLIENT_ID) {
return new NextResponse(null, { status: 401 });
}
// HMAC-SHA256 hex is always 64 lower-case hex chars. Reject malformed
// input BEFORE Buffer.from(..., "hex") — invalid hex decodes to a shorter
// buffer and timingSafeEqual would throw (length mismatch ⇒ 500).
if (!/^[0-9a-f]{64}$/i.test(sig)) {
return new NextResponse(null, { status: 401 });
}
const expected = createHmac("sha256", process.env.LOGI_RP_HEALTH_SECRET ?? "")
.update(`${ts}.${cid}`)
.digest("hex");
const ok = timingSafeEqual(
Buffer.from(sig, "hex"),
Buffer.from(expected, "hex")
);
if (!ok) {
return new NextResponse(null, { status: 401 });
}
return NextResponse.json({
status: "ok",
client_id: cid,
timestamp: new Date().toISOString(),
sdk_version: "logi-rp-integrate/1.0",
});
}Edge runtime
With runtime = "edge", node:crypto is unavailable. Always set runtime = "nodejs". You can build an equivalent with the Web Crypto API, but you would have to implement timingSafeEqual yourself — not recommended.
3. Enable it in the console
Developer Console → your app → check the RP active health check box → Save → copy the revealed secret into your env → redeploy.
4. Self-verification
TS=$(date +%s)
CID=$LOGI_CLIENT_ID
SIG=$(printf "%s.%s" "$TS" "$CID" | openssl dgst -sha256 -hmac "$LOGI_RP_HEALTH_SECRET" -hex | awk '{print $2}')
curl -i \
-H "x-logi-timestamp: $TS" \
-H "x-logi-client-id: $CID" \
-H "x-logi-signature: $SIG" \
"$YOUR_HOST/.well-known/logi-rp-health"
# Expect: 200 OK + body.client_id == $CID
# Note: HTTP headers are case-insensitive — the code reads lower-case keys, but any case works when sending.Confirm that the 🟢 indicator appears on the console card within an hour.