Express.js
Source:
integrations/express.md· Live: https://docs.1pass.dev/integrations/express LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L27-L77: ## SDK 빠른 시작 (권장)
- L78-L144: ## 수동 통합 (SDK 미사용)
- L145-L217: ## Health check endpoint
- L149-L155: ### 1. Env
- L156-L199: ### 2. Handler
- L200-L213: ### 3. App 에 mount
- L214-L217: ### 4. 콘솔에서 활성화
Express.js
⚠️ Warning: 📋 Confidential client 등록이 필요합니다 이 가이드의 코드는
client_secret을/oauth/token에 보냅니다 (서버에서 안전하게 보관). Developer Console 에서 RP 등록 시 Client type: Confidential 을 선택하세요. Public/SPA 클라이언트라면 Public Clients 가이드를 따르고client_secret을 절대 클라이언트 코드에 두지 마세요.
SDK 빠른 시작 (권장)
공식 Node SDK @logi-auth/server 가 authorize URL 생성 + code 교환 + id_token 검증(RS256) 을 한 번에 처리합니다. confidential 통합에서 서버가 audience(aud) 를 검증하는 것이 SoT — 아래 SDK 경로가 권장이고, 이어지는 수동 통합은 fallback 입니다.
npm install @logi-auth/server@^1.0.1 # v1.0.1 보안 하드닝 (at_hash 바인딩) 권장import express from "express";
import { randomBytes } from "node:crypto";
import cookieParser from "cookie-parser";
import { LogiAuthServer } from "@logi-auth/server";
const app = express();
app.use(cookieParser(process.env.COOKIE_SECRET));
const logi = new LogiAuthServer({
clientId: process.env.LOGI_CLIENT_ID,
clientSecret: process.env.LOGI_CLIENT_SECRET, // confidential — 서버 env 전용
redirectUri: process.env.LOGI_REDIRECT_URI,
// issuer 기본값 https://api.1pass.dev · tokenIssuer 기본값 "https://api.1pass.dev" · scopes 기본값 [openid, profile:basic, email]
});
app.get("/auth/login", (req, res) => {
const state = randomBytes(16).toString("hex");
const nonce = randomBytes(16).toString("hex");
// state·nonce 를 서명 쿠키/세션에 저장 (콜백에서 대조)
res.cookie("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
res.cookie("logi_nonce", nonce, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
res.redirect(logi.authorizationUrl({ state, nonce }));
});
app.get("/auth/callback", async (req, res) => {
if (!req.query.code || req.query.state !== req.cookies.logi_state) {
return res.status(400).send("state mismatch");
}
// exchangeCodeAndVerify: code 교환 + id_token 검증(alg==RS256 → kid → JWKS → 서명 → iss("https://api.1pass.dev") → aud → azp(멀티 aud 조건부) → exp → iat → nonce → sub → at_hash)을 한 번에
const session = await logi.exchangeCodeAndVerify({
code: String(req.query.code),
nonce: req.cookies.logi_nonce, // id_token.nonce 대조 (필수)
});
// session.sub — 검증 완료된 subject. 이 값으로 사용자 레코드 키잉
// session.email / session.accessToken / session.refreshToken / session.claims
res.clearCookie("logi_state"); res.clearCookie("logi_nonce");
res.redirect("/");
});LogiAuthServer/exchangeCodeAndVerify 는 id_token 을 alg(RS256) → kid → JWKS 조회 → RS256 서명검증 → iss(=="https://api.1pass.dev") → aud(내 client_id 포함) → azp(멀티 aud 일 때만, 값=client_id) → exp(skew 60s) → iat → nonce(사용 시) → sub → at_hash(마지막) 순서로 검증합니다 (scope 는 인가 축이라 검증 순서에 미포함). azp 는 멀티 aud 일 때만 필수(값=client_id) — SDK 가 조건부로 검증합니다. at_hash 바인딩 (v1.0.1): 계약은 at_hash = base64url_nopad(SHA256(access_token 의 UTF-8 바이트)[처음 16바이트]) (OIDC §3.1.3.6). present-only — id_token 에 at_hash 클레임이 있고 access_token 이 제공될 때만 검증하며, 불일치 시 at_hash_mismatch 로 거부합니다. 서버가 at_hash 를 발급하므로, exchangeCodeAndVerify 는 토큰 교환으로 받은 access_token 을 내부적으로 검증에 배선해 바꿔치기된 access_token 을 세션 반환 전에 거릅니다. PKCE(public) 변형은 clientSecret 생략 + authorizationUrl({ state, nonce, codeChallenge }) + exchangeCodeAndVerify({ code, nonce, codeVerifier }). 스탠드얼론 검증은 verifyIdToken(idToken, { jwks, expected }) — access_token 바인딩까지 확인하려면 verifyIdToken(idToken, { jwks, expected, accessToken }). v1.0.1 은 JWKS 에서 kty=="RSA" + use/alg 필터로 서명 키를 선택해 EC 키 혼입에 견고합니다(JWKS 레퍼런스). 에러는 LogiAuthServerError(.code)·IdTokenError(.code).
수동 통합 (SDK 미사용)
SDK 없이 직접 PKCE + 토큰 교환을 구현하는 fallback 입니다. access token 검증은 JWKS 로 하세요.
import express from "express";
import crypto from "node:crypto";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser(process.env.COOKIE_SECRET));
const LOGI = process.env.LOGI_API_URL;
const CLIENT_ID = process.env.LOGI_CLIENT_ID;
const CLIENT_SECRET = process.env.LOGI_CLIENT_SECRET;
const REDIRECT = process.env.LOGI_REDIRECT_URI;
function b64url(buf) {
return Buffer.from(buf).toString("base64url");
}
app.get("/auth/login", (req, res) => {
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
const state = b64url(crypto.randomBytes(16));
res.cookie("logi_pkce", verifier, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
res.cookie("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
const url = new URL(`${LOGI}/oauth/authorize`);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT);
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");
res.redirect(url.toString());
});
app.get("/auth/callback", async (req, res) => {
// code/state 누락 검증 — 악의적 callback 주입 방지
if (!req.query.code || !req.query.state) {
return res.status(400).send("missing code or state");
}
if (req.query.state !== req.cookies.logi_state) return res.status(400).send("state mismatch");
const tokens = await fetch(`${LOGI}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: req.query.code,
redirect_uri: REDIRECT,
code_verifier: req.cookies.logi_pkce,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
}).then(r => r.json());
res.cookie("logi_rt", tokens.refresh_token, { httpOnly: true, secure: true, sameSite: "strict" });
res.clearCookie("logi_pkce");
res.clearCookie("logi_state");
res.redirect("/");
});Health check endpoint
RP 활성 헬스 체크 프로토콜의 Express 측 핸들러입니다.
1. Env
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<콘솔에서 복사>2. Handler
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_SKEW = 300; // seconds
app.get("/.well-known/logi-rp-health", (req, res) => {
// HTTP 헤더는 case-insensitive — 코드는 소문자 키로 받지만 발송 시 어느 케이스든 동작.
const ts = Number(req.get("x-logi-timestamp"));
const cid = String(req.get("x-logi-client-id") || "");
const sig = String(req.get("x-logi-signature") || "");
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_SKEW) {
return res.sendStatus(401);
}
if (cid !== process.env.LOGI_CLIENT_ID) {
return res.sendStatus(401);
}
// HMAC-SHA256 hex is always 64 hex chars. Reject malformed input BEFORE
// Buffer.from(..., "hex") — invalid hex decodes to a shorter buffer and
// timingSafeEqual would throw on length mismatch (⇒ 500).
if (!/^[0-9a-f]{64}$/i.test(sig)) {
return res.sendStatus(401);
}
const expected = createHmac("sha256", process.env.LOGI_RP_HEALTH_SECRET || "")
.update(`${ts}.${cid}`)
.digest("hex");
if (!timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) {
return res.sendStatus(401);
}
res.json({
status: "ok",
client_id: cid,
timestamp: new Date().toISOString(),
sdk_version: "logi-rp-integrate/1.0",
});
});3. App 에 mount
위 handler 를 별도 router 파일로 분리한 경우 app.use 로, 같은 app 에 직접 정의한 경우는 그대로 동작합니다.
// app.js (또는 server.js)
import logiHealthRouter from "./routes/logi-rp-health.js";
app.use(logiHealthRouter);
// 또는 handler 만 분리한 경우 직접 마운트:
// app.get("/.well-known/logi-rp-health", logiHealthHandler);Express router 패턴이면 위처럼 mount. handler 만 분리한 경우 두 번째 형태.
4. 콘솔에서 활성화
개발자 콘솔 → 앱 → RP active health check ON → 저장 → 시크릿을 env 에 복사 → 재배포 → 1시간 이내 카드에 🟢.