Skip to content

SPA + serverless (Vercel)

A guide to adding logi login to an app with no backend of its own, such as a Vite / CRA / static-build SPA. Because serverless functions (/api/auth/*) handle the token exchange, this app is a Confidential clientclient_secret lives in the function's environment variables, not in the browser.

📋 Register as a Confidential client

The secret is held by serverless functions, so this is Confidential. Only a pure SPA (zero server code) where the browser calls /oauth/token directly is Public.

Architecture

[Browser SPA]  login click → GET /api/auth/login (serverless)
     │                              │ generate PKCE + state → temporarily store in httpOnly cookie
     │                              ▼ 302
     │                       {logi}/oauth/authorize
     │                              │ user authentication & consent
     ▼                              ▼ 302 ?code&state
[Serverless] GET /api/auth/callback
     │   verify state → POST {logi}/oauth/token (client_secret_basic)
     │   sub from id_token + profile from userinfo → issue a signed session cookie (httpOnly) → 302 /

[Browser]  GET /api/auth/me → { user } (based on the session cookie)

The key point: the logi access/refresh token is visible only inside the serverless functions, and the browser holds only the app's own session cookie. This is a stateless RP pattern that stores no tokens.

1. RP registration

Developer Console:

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

client_secret is shown only once, right after you save.

2. Environment variables (Vercel project settings)

bash
ONE_PASS_URL=https://api.1pass.dev
ONE_PASS_CLIENT_ID=logi_...
ONE_PASS_CLIENT_SECRET=...           # accessed only in serverless functions, never exposed to the browser
ONE_PASS_SCOPES=openid profile:basic email
SESSION_SECRET=<openssl rand -hex 32>  # for HMAC-signing the session cookie — required

SESSION_SECRET must be set in production

The session cookie is HMAC-signed with this value. Without it, the cookie can be forged to impersonate any user, so in production make write requests fail-closed (blocked) when it is unset.

3. Shared utilities (api/_lib.js)

PKCE generation, the HMAC-signed session cookie, and the token exchange are all handled with Node's built-in crypto, without external dependencies.

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 days

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-signed session (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);
}

// cookies
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);

// decode the id_token payload (received directly over TLS from the token endpoint → no need to re-verify the signature)
export function decodeIdToken(idToken) {
  if (!idToken || idToken.split('.').length < 2) return null;
  return fromB64urlJson(idToken.split('.')[1]);
}

4. Login start (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`;

  // one-time cookies kept only for the round-trip (10 minutes)
  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,                 // includes openid → id_token issued
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  res.writeHead(302, { Location: `${LOGI.authorizeUrl}?${params}` });
  res.end();
}

5. Callback — token exchange + session issuance (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 defense

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

  // Confidential client: the secret is required. If missing, it is a deployment misconfiguration → 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 comes from the id_token (openid scope required); the profile (email/name) comes from userinfo.
  // The logi id_token carries only OIDC-standard claims (sub/iss/aud/exp/iat/at_hash) and
  // does not include email or name, so the profile must be fetched with a userinfo call.
  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();   // even on failure, login proceeds (name simply left blank)
  }
  // An anonymous account may return anonymous:true + a synthetic email
  // (anon+<hash>@1pass.internal) in userinfo. It is not a real address, so discard it and
  // identify the account by the anonymous flag only (do not infer from the presence of name/email).
  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');   // falls through here if openid is missing

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

sub from id_token, profile from userinfo

sub (the stable user identifier) can be trusted directly from the id_token payload received over TLS from /oauth/token (the OIDC §3.1.3.7 #6 exception — no signature re-verification needed). But because email and name are not in the id_token, you fetch them by calling /oauth/userinfo with the access_token. The userinfo keys are email, name (=full_name), and nickname (=preferred_name).

Anonymous account / consent / unset — do not assume email and name are "always present"

Even for the same "logged-in user," the case varies:

  • Anonymous logi account: userinfo may carry anonymous: true + a synthetic email (anon+<hash>@1pass.internal) — not a real address. Identify by the anonymous boolean and discard the synthetic email.
  • per-claim consent: even if you request the email/profile:basic scope, if the user does not select that claim on the consent screen, the key is omitted from the response. scope ≠ guaranteed receipt.
  • Email unset: a Hide-My-Email / SSO-only user may not have an email at all.

If you truly need identity, key on sub (always present) and treat email and name as optional information.

JWKS signature verification is required only when the id_token arrives over an untrusted path (front-channel, or stored and reused).

6. Session lookup / logout

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

A stateless RP does not need revoke

This pattern does not store the logi access/refresh token, so on logout you only clear your own session cookie. A /oauth/revoke call is needed only when you store tokens on the server.

7. Using it from the browser

js
// login: navigate to the serverless route
location.href = '/api/auth/login';

// look up the session on mount
const { user } = await fetch('/api/auth/me', { credentials: 'same-origin' }).then(r => r.json());

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

8. SPA rewrite (vercel.json)

Route every path except /api/* to index.html (client-side routing).

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

Troubleshooting

SymptomCause
no_subject_in_id_tokenopenid missing from scope → no id_token issued. Check ONE_PASS_SCOPES
Name empty after loginprofile:basic not requested, consent not checked, or the user has no name set → UI fallback
Email is anon+...@1pass.internalAn anonymous account's synthetic email → identify via anonymous:true, do not display it
invalid_clientConfidential but secret missing, or sending a secret to a Public RP
state_mismatchOne-time cookie expired (over 10 minutes) or cookies blocked
Write API returns 503SESSION_SECRET unset in production → fail-closed

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