Skip to content

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.

bash
npm install @logi-auth/browser
ts
// 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

bash
# .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/callback

2. Login start route

ts
// 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

ts
// 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));
}

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).

tsx
// ✅ 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

ts
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

bash
# .env.local (or Vercel/Render env)
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<copy from the console>

2. Route handler

ts
// 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

bash
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.

Identity가 제품의 신뢰를 만듭니다.