Skip to content

Next.js (App Router)

📋 Confidential client 등록이 필요합니다

이 가이드의 코드는 client_secret/oauth/token 에 보냅니다 (서버에서 안전하게 보관). Developer Console 에서 RP 등록 시 Client type: Confidential 을 선택하세요. Public/SPA 클라이언트라면 Public Clients 가이드를 따르고 client_secret 을 절대 클라이언트 코드에 두지 마세요.

SDK 빠른 시작 (Client SPA)

브라우저에서 직접 PKCE 를 시작·완료하는 SPA 페이지가 있다면 npm 패키지를 사용하세요. PKCE, state, 토큰 회전, LogiAuthError (8 코드) 매핑 모두 내장.

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 는 /.well-known/openid-configuration 에 OIDC discovery 문서를 제공하므로 일반 OIDC 클라이언트 (oauth4webapi, openid-client, next-auth, auth.js) 가 issuer: 'https://api.1pass.dev' 한 줄로 자동 구성됩니다. 의존성을 추가하지 않으려면 아래 hand-rolled route handler 예시를 사용하세요 — 동일하게 동작합니다.


로그인 버튼 → Authorization Code + PKCE 풀 플로우.

1. 환경변수

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. 로그인 시작 라우트

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. 콜백 라우트

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 자동 커버)
  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));
}

OAuth start 라우트(/api/auth/login)는 반드시 native <a href> 로 트리거하세요. next/link (<Link>) 는 client-side router 가 navigation 을 가로채서 fetch() 로 처리하는데, 같은 origin → api.1pass.dev 로의 cross-origin 302 를 따라가다 CORS 로 silently fail 합니다 (클릭해도 아무 반응 없음).

tsx
// ✅ 정상
<a href="/api/auth/login">1pass 로 로그인</a>

// ❌ 클릭해도 반응 없음
import Link from "next/link";
<Link href="/api/auth/login">1pass 로 로그인</Link>

자세한 진단/해결: Troubleshooting · 시나리오 8.

5. 보호된 API에서 userinfo 조회

ts
const me = await fetch(`${process.env.LOGI_API_URL}/oauth/userinfo`, {
  headers: { Authorization: `Bearer ${access_token}` },
}).then(r => r.json());

JWT 검증(stateless)을 선호하면 jose로 JWKS 검증.

Health check endpoint

RP 활성 헬스 체크 프로토콜의 Next.js (App Router) 측 핸들러입니다. 매시간 1pass IdP 가 /.well-known/logi-rp-health 로 HMAC 서명된 GET 을 보내고 RP 가 자신의 등록 client_id 를 echo 하면 콘솔에 🟢 으로 표시됩니다.

1. Env 변수

bash
# .env.local (또는 Vercel/Render env)
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<콘솔에서 복사>

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 — 매 요청 재서명 필요
export const runtime = "nodejs";        // node:crypto 필요

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

runtime = "edge" 로 두면 node:crypto 가 안 됩니다. 반드시 runtime = "nodejs" 명시. Web Crypto API 로도 동등 구현 가능하지만 timingSafeEqual 직접 구현 필요 — 권장하지 않습니다.

3. 콘솔에서 활성화

개발자 콘솔 → 앱 → RP active health check 체크박스 ON → 저장 → 노출된 시크릿을 env 에 복사 → 재배포.

4. 자가 검증

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"
# 기대: 200 OK + body.client_id == $CID
# 참고: HTTP 헤더는 case-insensitive — 코드는 소문자 키로 받지만 발송 시 어느 케이스든 동작.

1시간 이내 콘솔 카드에 🟢 표시되는지 확인.

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