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 등록
- Client type: Confidential
- Redirect URI:
https://your-app.vercel.app/api/auth/callback - Scopes:
openid,profile:basic,email
client_secret 은 저장 직후 한 번만 표시됩니다.
2. 환경변수 (Vercel 프로젝트 설정)
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 로 처리합니다.
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)
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)
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) 이 올 수 있음 — 진짜 메일 아님.anonymousboolean 으로 식별하고 synthetic 이메일은 버리세요. - per-claim consent:
email/profile:basicscope 를 요청해도 사용자가 동의창에서 해당 claim 을 체크하지 않으면 그 키는 응답에서 빠집니다. scope ≠ 수신 보장. - 이메일 미설정: Hide-My-Email / SSO-only 사용자는
email자체가 없을 수 있음.
신원이 꼭 필요하면 sub(항상 존재) 로 키를 잡고, email·name 은 선택 정보로 다루세요.
id_token 이 신뢰할 수 없는 경로(front-channel, 저장 후 재사용)로 들어오는 경우에만 JWKS 서명 검증이 필수입니다.
6. 세션 조회 / 로그아웃
// 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) });
}// 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. 브라우저에서 사용
// 로그인: 서버리스 라우트로 이동
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 로 (클라이언트 라우팅).
{
"framework": "vite",
"rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }]
}트러블슈팅
| 증상 | 원인 |
|---|---|
no_subject_in_id_token | scope 에 openid 누락 → id_token 미발급. ONE_PASS_SCOPES 확인 |
| 로그인 후 이름이 비어 있음 | profile:basic 미요청, consent 미체크, 또는 사용자가 이름 미설정 → UI 폴백 |
이메일이 anon+...@1pass.internal | 익명 계정의 synthetic 이메일 → anonymous:true 로 식별, 표시하지 말 것 |
invalid_client | Confidential 인데 secret 누락, 또는 Public RP 에 secret 전송 |
state_mismatch | 일회성 쿠키 만료(10분 초과) 또는 쿠키 차단 |
| 쓰기 API 가 503 | 운영에서 SESSION_SECRET 미설정 → fail-closed |
관련 문서
- Quickstart · Public vs Confidential
- Scope 레퍼런스 · JWKS 검증 · PKCE
- Next.js — App Router Route Handlers 버전