Skip to content

SPA + 서버리스 (Vercel)

Vite / CRA / 정적 빌드 SPA 처럼 자체 백엔드가 없는 앱에 logi 로그인을 붙이는 가이드입니다. 토큰 교환은 서버리스 함수(/api/auth/*)가 담당하므로 이 앱은 Confidential client 입니다 — client_secret 은 브라우저가 아니라 함수 환경변수에 둡니다.

📋 Confidential client 로 등록하세요

secret 을 서버리스 함수가 보관하므로 Confidential 입니다. 브라우저가 직접 /oauth/token 을 호출하는 순수 SPA(서버 코드 0)만 Public 입니다.

아키텍처

[브라우저 SPA]  로그인 클릭 → GET /api/auth/login (서버리스)
     │                              │ PKCE + state 생성 → httpOnly 쿠키 임시 저장
     │                              ▼ 302
     │                       {logi}/oauth/authorize
     │                              │ 사용자 인증·동의
     ▼                              ▼ 302 ?code&state
[서버리스] GET /api/auth/callback
     │   state 대조 → POST {logi}/oauth/token (client_secret_basic)
     │   id_token 에서 sub + userinfo 에서 프로필 → 서명 세션쿠키(httpOnly) 발급 → 302 /

[브라우저]  GET /api/auth/me → { user } (세션쿠키 기반)

핵심: logi access/refresh token 은 서버리스 함수 안에서만 보이고, 브라우저는 앱 자체 세션쿠키만 갖습니다. 토큰을 저장하지 않는 stateless RP 패턴입니다.

1. RP 등록

Developer Console:

  • Client type: Confidential
  • Redirect URI: https://your-app.vercel.app/api/auth/callback
  • Scopes: openid, profile:basic, email

client_secret 은 저장 직후 한 번만 표시됩니다.

2. 환경변수 (Vercel 프로젝트 설정)

bash
ONE_PASS_URL=https://api.1pass.dev
ONE_PASS_CLIENT_ID=logi_...
ONE_PASS_CLIENT_SECRET=...           # 서버리스 함수에서만 접근, 브라우저 노출 금지
ONE_PASS_SCOPES=openid profile:basic email
SESSION_SECRET=<openssl rand -hex 32>  # 세션쿠키 HMAC 서명용 — 필수

SESSION_SECRET 은 운영에서 반드시 설정

세션쿠키는 이 값으로 HMAC 서명됩니다. 없으면 쿠키 위조로 임의 사용자를 가장할 수 있으므로, 운영에서는 미설정 시 쓰기 요청을 fail-closed(차단) 하도록 만드세요.

3. 공용 유틸 (api/_lib.js)

PKCE 생성, HMAC 서명 세션쿠키, 토큰 교환을 외부 의존성 없이 Node 내장 crypto 로 처리합니다.

js
import crypto from 'node:crypto';

export const LOGI = {
  base: process.env.ONE_PASS_URL || 'https://api.1pass.dev',
  clientId: process.env.ONE_PASS_CLIENT_ID || '',
  clientSecret: process.env.ONE_PASS_CLIENT_SECRET || '',
  scopes: process.env.ONE_PASS_SCOPES || 'openid profile:basic email',
  get authorizeUrl() { return `${this.base}/oauth/authorize`; },
  get tokenUrl()     { return `${this.base}/oauth/token`; },
};

const SESSION_SECRET = process.env.SESSION_SECRET || 'dev-insecure-change-me';
const SESSION_COOKIE = 'app_session';
const SESSION_MAX_AGE = 60 * 60 * 24 * 14; // 14일

const b64url = (b) => Buffer.from(b).toString('base64')
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const fromB64urlJson = (s) => {
  try { return JSON.parse(Buffer.from(s.replace(/-/g,'+').replace(/_/g,'/'),'base64').toString()); }
  catch { return null; }
};

// PKCE
export function generatePkce() {
  const verifier = b64url(crypto.randomBytes(32));
  const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}
export const randomState = () => b64url(crypto.randomBytes(24));

// HMAC 서명 세션 (payload.signature)
const sign = (p) => crypto.createHmac('sha256', SESSION_SECRET).update(p).digest('base64url');
export function signSession(data) {
  const payload = b64url(JSON.stringify({ ...data, iat: Math.floor(Date.now()/1000) }));
  return `${payload}.${sign(payload)}`;
}
export function verifySession(token) {
  if (!token || !token.includes('.')) return null;
  const [payload, sig] = token.split('.');
  const expected = sign(payload);
  if (sig.length !== expected.length) return null;
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
  return fromB64urlJson(payload);
}

// 쿠키
export function parseCookies(req) {
  const out = {};
  (req.headers.cookie || '').split(';').forEach((p) => {
    const i = p.indexOf('='); if (i > -1) out[p.slice(0,i).trim()] = decodeURIComponent(p.slice(i+1).trim());
  });
  return out;
}
function appendHeader(res, k, v) {
  const prev = res.getHeader(k);
  res.setHeader(k, !prev ? v : Array.isArray(prev) ? [...prev, v] : [prev, v]);
}
export function setCookie(res, name, value, { maxAge } = {}) {
  const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'SameSite=Lax', 'Secure', 'HttpOnly'];
  if (maxAge !== undefined) parts.push(`Max-Age=${maxAge}`);
  appendHeader(res, 'Set-Cookie', parts.join('; '));
}
export function clearCookie(res, name) {
  appendHeader(res, 'Set-Cookie', `${name}=; Path=/; Max-Age=0; SameSite=Lax; Secure; HttpOnly`);
}
export const setSession   = (res, user) => setCookie(res, SESSION_COOKIE, signSession({ user }), { maxAge: SESSION_MAX_AGE });
export const getSession   = (req) => verifySession(parseCookies(req)[SESSION_COOKIE])?.user || null;
export const clearSession = (res) => clearCookie(res, SESSION_COOKIE);

// id_token 페이로드 디코드 (토큰 엔드포인트에서 TLS 로 직접 수신 → 서명 재검증 불요)
export function decodeIdToken(idToken) {
  if (!idToken || idToken.split('.').length < 2) return null;
  return fromB64urlJson(idToken.split('.')[1]);
}

4. 로그인 시작 (api/auth/login.js)

js
import { LOGI, generatePkce, randomState, setCookie } from '../_lib.js';

export default function handler(req, res) {
  const { verifier, challenge } = generatePkce();
  const state = randomState();
  const proto = (req.headers['x-forwarded-proto'] || 'https').split(',')[0];
  const host  = req.headers['x-forwarded-host'] || req.headers.host;
  const redirectUri = `${proto}://${host}/api/auth/callback`;

  // 라운드트립 동안만 유지되는 일회성 쿠키 (10분)
  setCookie(res, 'logi_state', state, { maxAge: 600 });
  setCookie(res, 'logi_verifier', verifier, { maxAge: 600 });
  setCookie(res, 'logi_redirect', redirectUri, { maxAge: 600 });

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: LOGI.clientId,
    redirect_uri: redirectUri,
    scope: LOGI.scopes,                 // openid 포함 → id_token 발급
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  res.writeHead(302, { Location: `${LOGI.authorizeUrl}?${params}` });
  res.end();
}

5. 콜백 — 토큰 교환 + 세션 발급 (api/auth/callback.js)

js
import { LOGI, parseCookies, clearCookie, setSession, decodeIdToken } from '../_lib.js';

export default async function handler(req, res) {
  const { code, state, error, error_description } = req.query;
  const cookies = parseCookies(req);
  ['logi_state','logi_verifier','logi_redirect'].forEach((c) => clearCookie(res, c));

  const fail = (m) => { res.writeHead(302, { Location: `/?auth_error=${encodeURIComponent(m)}` }); res.end(); };
  if (error) return fail(error_description || error);
  if (!code) return fail('missing_code');
  if (!state || state !== cookies.logi_state) return fail('state_mismatch');  // CSRF 방어

  const verifier = cookies.logi_verifier, redirectUri = cookies.logi_redirect;
  if (!verifier || !redirectUri) return fail('session_expired');

  // Confidential client: secret 은 필수. 없으면 배포 설정 오류 → fail-closed.
  if (!LOGI.clientSecret) return fail('server_misconfigured_no_secret');
  const headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    Authorization: `Basic ${Buffer.from(`${LOGI.clientId}:${LOGI.clientSecret}`).toString('base64')}`,
  };
  const body = new URLSearchParams({
    grant_type: 'authorization_code', code, redirect_uri: redirectUri,
    client_id: LOGI.clientId, code_verifier: verifier,
  });

  const tokenRes = await fetch(LOGI.tokenUrl, { method: 'POST', headers, body });
  const raw = await tokenRes.text();
  if (!tokenRes.ok) return fail(`token_exchange_${tokenRes.status}`);

  const tokens = JSON.parse(raw);

  // sub 는 id_token 에서 (openid scope 필요), 프로필(email/name)은 userinfo 에서.
  // logi id_token 은 OIDC 표준 claim(sub/iss/aud/exp/iat/at_hash)만 담고
  // email·name 은 넣지 않으므로, 프로필은 userinfo 호출로 받아야 한다.
  const claims = decodeIdToken(tokens.id_token) || {};
  let profile = {};
  if (tokens.access_token) {
    const ui = await fetch(`${LOGI.base}/oauth/userinfo`, {
      headers: { Authorization: `Bearer ${tokens.access_token}` },
    });
    if (ui.ok) profile = await ui.json();   // 실패해도 로그인은 진행(이름만 빈 채)
  }
  // 익명 계정은 userinfo 에 anonymous:true + synthetic 이메일
  // (anon+<hash>@1pass.internal)이 올 수 있다. 진짜 메일이 아니므로 버리고
  // anonymous 플래그로만 식별한다(이름/이메일 유무로 추측 금지).
  const isAnon = profile.anonymous === true;
  const synthetic = typeof profile.email === 'string' && profile.email.endsWith('@1pass.internal');
  const user = {
    sub: claims.sub || profile.sub || null,
    anonymous: isAnon,
    email: isAnon || synthetic ? null : profile.email || null,
    name: isAnon ? null : profile.name || profile.nickname || null,  // userinfo: name=full_name, nickname=preferred_name
  };
  if (!user.sub) return fail('no_subject_in_id_token');   // openid 누락 시 여기 걸림

  setSession(res, user);
  res.writeHead(302, { Location: '/?auth=ok' });
  res.end();
}

sub 는 id_token, 프로필은 userinfo

sub(안정적 사용자 식별자)는 /oauth/token 에서 TLS 로 직접 받은 id_token 페이로드에서 바로 신뢰할 수 있습니다 (OIDC §3.1.3.7 #6 예외 — 서명 재검증 불요). 단 email·name 은 id_token 에 없으므로 access_token 으로 /oauth/userinfo 를 호출해 받습니다. userinfo 키는 email, name(=full_name), nickname(=preferred_name) 입니다.

익명 계정 / consent / 미설정 — email·name 은 "항상 온다"고 가정 금지

같은 "로그인된 사용자"라도 케이스마다 다릅니다:

  • 익명 logi 계정: userinfo 에 anonymous: true + synthetic 이메일(anon+<hash>@1pass.internal) 이 올 수 있음 — 진짜 메일 아님. anonymous boolean 으로 식별하고 synthetic 이메일은 버리세요.
  • per-claim consent: email/profile:basic scope 를 요청해도 사용자가 동의창에서 해당 claim 을 체크하지 않으면 그 키는 응답에서 빠집니다. scope ≠ 수신 보장.
  • 이메일 미설정: Hide-My-Email / SSO-only 사용자는 email 자체가 없을 수 있음.

신원이 꼭 필요하면 sub(항상 존재) 로 키를 잡고, email·name 은 선택 정보로 다루세요.

id_token 이 신뢰할 수 없는 경로(front-channel, 저장 후 재사용)로 들어오는 경우에만 JWKS 서명 검증이 필수입니다.

6. 세션 조회 / 로그아웃

js
// api/auth/me.js
import { getSession } from '../_lib.js';
export default function handler(req, res) {
  res.setHeader('Cache-Control', 'no-store');
  res.status(200).json({ user: getSession(req) });
}
js
// api/auth/logout.js
import { clearSession } from '../_lib.js';
export default function handler(req, res) {
  clearSession(res);
  if (req.method === 'GET') { res.writeHead(302, { Location: '/' }); res.end(); return; }
  res.status(200).json({ ok: true });
}

stateless RP 는 revoke 불필요

이 패턴은 logi access/refresh token 을 저장하지 않으므로 로그아웃 시 자체 세션쿠키만 지우면 됩니다. 토큰을 서버에 저장하는 경우에만 /oauth/revoke 호출이 필요합니다.

7. 브라우저에서 사용

js
// 로그인: 서버리스 라우트로 이동
location.href = '/api/auth/login';

// 마운트 시 세션 조회
const { user } = await fetch('/api/auth/me', { credentials: 'same-origin' }).then(r => r.json());

// 로그아웃
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });

8. SPA rewrite (vercel.json)

/api/* 를 제외한 모든 경로를 index.html 로 (클라이언트 라우팅).

json
{
  "framework": "vite",
  "rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }]
}

트러블슈팅

증상원인
no_subject_in_id_tokenscope 에 openid 누락 → id_token 미발급. ONE_PASS_SCOPES 확인
로그인 후 이름이 비어 있음profile:basic 미요청, consent 미체크, 또는 사용자가 이름 미설정 → UI 폴백
이메일이 anon+...@1pass.internal익명 계정의 synthetic 이메일 → anonymous:true 로 식별, 표시하지 말 것
invalid_clientConfidential 인데 secret 누락, 또는 Public RP 에 secret 전송
state_mismatch일회성 쿠키 만료(10분 초과) 또는 쿠키 차단
쓰기 API 가 503운영에서 SESSION_SECRET 미설정 → fail-closed

관련 문서

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