테마
Widget SDK (embed.1pass.dev)
UMD JavaScript 한 줄과 <div> 한 개로 RP 페이지 안에 logi QR 로그인 위젯이 떠오릅니다. iframe 으로 격리되어 RP 페이지의 cookie / DOM 과 분리됩니다.
UI 통제가 더 필요하면 Device Flow, 페이지 redirect 가 가능하면 표준 Code Flow 가 더 단순합니다.
백엔드 작업 필요
Widget SDK 는 confidential client + PKCE 흐름입니다. RP 백엔드는 다음을 구현해야 합니다:
- PKCE
code_verifier생성 + 서버 측 저장 (세션/캐시, key=state) code_challenge를 위젯에 내려주는 엔드포인트 (예:/api/auth/1pass/challenge)- 위젯이 받은
code를client_secret+code_verifier와 함께/oauth/token으로 교환하는 엔드포인트
client_secret 은 RP 백엔드에서만 사용. 브라우저로 절대 내려보내지 마세요.
PKCE 셋업 (RP 백엔드 — 필수 선행 작업)
위젯은 PKCE code_challenge 없이는 마운트되지 않습니다.
규칙:
code_verifier: 43~128자 unreserved 문자열 (RFC 7636 §4.1). 권장: 32바이트 랜덤 → BASE64URL.code_challenge:BASE64URL(SHA256(code_verifier))(S256 method).code_verifier는 절대 브라우저로 보내지 말 것. 서버 세션/Redis 등에state키로 저장.- 1회용. 토큰 교환 후 즉시 폐기.
Verifier / Challenge 생성 예시
Ruby (Rails):
ruby
require "securerandom"
require "digest"
require "base64"
def generate_pkce_pair
verifier = SecureRandom.urlsafe_base64(32) # ~43 chars, no padding
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
[verifier, challenge]
end
# In your controller:
verifier, challenge = generate_pkce_pair
state = SecureRandom.hex(16)
session[:logi_pkce] = { state => verifier } # short-lived, e.g. 10 min TTL
render json: { state: state, code_challenge: challenge }Node.js:
javascript
import { randomBytes, createHash } from "node:crypto";
function generatePkcePair() {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}curl (셸 검증용):
bash
VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
echo "verifier=$VERIFIER"
echo "challenge=$CHALLENGE"두 가지 마운트 모드
| 모드 | data-* 속성 | UX | 언제 |
|---|---|---|---|
| 버튼 + 모달 (권장) | data-logi-button | 버튼 클릭 → 풀 사이즈 모달에 QR (360×540) | 대부분의 RP |
| 인라인 iframe | data-logi-qr | RP 페이지에 작은 iframe (320×460) 박힘 | 대시보드 등 항상 노출 필요 |
버튼 + 모달은 QR 가독성 / iframe 차단 fallback / 레이아웃 충돌 회피 측면에서 우월합니다 (Auth0 Lock, Kakao SDK, Stripe Checkout 동일 패턴).
Quick Start
RP 백엔드가 /api/auth/1pass/challenge 로 { state, code_challenge } 를 발급하고, /api/auth/1pass/exchange 로 code 를 토큰으로 교환한다고 가정.
권장: 버튼 + 모달
html
<!DOCTYPE html>
<html>
<head>
<script src="https://embed.1pass.dev/widget.js" defer></script>
</head>
<body>
<div id="logi-mount"
data-logi-button
data-client-id="logi_xxxxxxxxxxxx"
data-redirect-uri="https://your-app.com/callback"
data-on-success="handleLogin"
data-on-error="handleError"></div>
<script>
// 1) RP 백엔드에서 state + code_challenge 받아오기
fetch("/api/auth/1pass/challenge", { method: "POST" })
.then((r) => r.json())
.then(({ state, code_challenge }) => {
const mount = document.getElementById("logi-mount");
mount.setAttribute("data-state", state);
mount.setAttribute("data-code-challenge", code_challenge);
if (window.LogiWidget) window.LogiWidget.mountButton(mount);
});
// 2) 모달 안의 위젯이 logi.success 이벤트로 code 를 부모에게 전달
function handleLogin({ code, state }) {
fetch("/api/auth/1pass/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, state }),
}).then(() => location.assign("/dashboard"));
}
function handleError({ error, error_description }) {
console.error("logi error:", error, error_description);
}
</script>
</body>
</html>인라인 iframe
data-logi-button 대신 data-logi-qr 마커를 쓰고, LogiWidget.mountButton 대신 LogiWidget.mountWidget(mount) 를 호출. 나머지 속성/콜백은 위와 동일. 사용자 취소는 콜백 속성이 없으므로 mount.addEventListener("logi.cancelled", ...) 로 받음.
토큰 교환 (RP 백엔드)
bash
# RP 백엔드에서 호출 (브라우저 ❌)
curl -X POST https://api.1pass.dev/oauth/token \
-d "grant_type=authorization_code" \
-d "code=$CODE_FROM_WIDGET" \
-d "redirect_uri=https://your-app.com/callback" \
-d "client_id=$LOGI_CLIENT_ID" \
-d "client_secret=$LOGI_CLIENT_SECRET" \
-d "code_verifier=$STORED_VERIFIER" # 세션에서 state 키로 꺼낸 값SPA 단독 통합도 가능
백엔드 없이 브라우저에서 직접 /oauth/token 호출하려면 client_secret 없는 public client 로 등록하세요. PKCE-only 로 동작합니다. 자세한 절차는 Public Clients 가이드.
data-* 속성
| 속성 | 필수 | 설명 |
|---|---|---|
data-logi-qr / data-logi-button | ✅ | 마커 속성. 마운트 모드 결정 |
data-client-id | ✅ | RP 의 OAuth Application client_id |
data-redirect-uri | ✅ | 콜백 URL. application 등록 시 입력한 값과 정확히 일치해야 함 |
data-code-challenge | ✅ | PKCE code_challenge (BASE64URL(SHA256(verifier))). 누락 시 마운트 중단 |
data-code-challenge-method | — | PKCE method. 기본 S256 (현재 S256 만 지원) |
data-scope | — | 공백 구분 scope (예: openid profile email) |
data-state | — | CSRF 방지용 state. 생략 시 자동 생성. verifier 매핑 키로 쓰려면 RP 가 직접 발급 권장 |
data-on-success | — | 성공 시 호출할 전역 함수 이름 (window[name]) |
data-on-error | — | 에러 시 호출할 전역 함수 이름 |
미구현 속성
- 사용자 취소 콜백 (
data-on-cancelled) 미지원 →logi.cancelledCustomEvent 로 받으세요. data-theme/data-lang미지원 (v1.x roadmap).
postMessage 프로토콜
위젯은 window.postMessage 로 부모 창에 이벤트를 전달하고, 동시에 마운트 컨테이너에서 동일 이름의 CustomEvent 도 dispatch 합니다 (bubbles: true).
javascript
// 방식 1: window.postMessage 직접 리스닝
window.addEventListener("message", (event) => {
// ✅ origin 검증 필수
if (event.origin !== "https://embed.1pass.dev") return;
const { type, ...payload } = event.data;
switch (type) {
case "logi.success": handleLogin(payload); break; // { code, state }
case "logi.error": handleError(payload); break; // { error, error_description }
case "logi.cancelled": handleCancel(); break; // {}
}
});
// 방식 2: 마운트 컨테이너의 CustomEvent (origin 검증을 위젯이 대신 해줌)
const mount = document.querySelector("[data-logi-qr]");
mount.addEventListener("logi.success", (e) => handleLogin(e.detail));
mount.addEventListener("logi.error", (e) => handleError(e.detail));
mount.addEventListener("logi.cancelled", (e) => handleCancel());type | payload | 의미 |
|---|---|---|
logi.success | { code, state } | 사용자 승인 완료. RP 백엔드에서 /oauth/token 교환 |
logi.error | { error, error_description } | OAuth 에러. error 값은 표준 OAuth 에러 코드 |
logi.cancelled | {} | 사용자가 위젯 닫기 / 취소 클릭 |
RP 등록 (콘솔)
logi 콘솔(/console)에서 application 의 Widget Origins 에 RP 도메인을 추가하세요.
widget_origins:
- https://your-app.com
- https://staging.your-app.com
- http://localhost:5173이 값이 iframe 응답의 Content-Security-Policy: frame-ancestors 헤더에 박힙니다. 미등록 origin 은 브라우저가 iframe 렌더를 차단합니다.
프레임워크 통합 예시
PKCE 셋업 (/api/auth/1pass/challenge 가 { state, code_challenge } 발급) 이 RP 백엔드에 있다고 가정.
React
jsx
// LogiQrButton.jsx
import { useEffect, useRef, useState } from "react";
export function LogiQrButton({ clientId, redirectUri, onSuccess, onError }) {
const ref = useRef(null);
const [pkce, setPkce] = useState(null);
useEffect(() => {
fetch("/api/auth/1pass/challenge", { method: "POST" })
.then((r) => r.json())
.then(setPkce);
}, []);
useEffect(() => {
if (!pkce || !ref.current || !window.LogiWidget) return;
window.__logiOnSuccess = onSuccess;
window.__logiOnError = onError;
window.LogiWidget.mountWidget(ref.current);
return () => {
delete window.__logiOnSuccess;
delete window.__logiOnError;
};
}, [pkce, onSuccess, onError]);
if (!pkce) return null;
return (
<div
ref={ref}
data-logi-qr
data-client-id={clientId}
data-redirect-uri={redirectUri}
data-code-challenge={pkce.code_challenge}
data-state={pkce.state}
data-on-success="__logiOnSuccess"
data-on-error="__logiOnError"
/>
);
}
// _app.jsx — 한 번만 로드
<script src="https://embed.1pass.dev/widget.js" defer />Vue 3
vue
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
const props = defineProps(["clientId", "redirectUri"]);
const emit = defineEmits(["success", "error", "cancelled"]);
const mountRef = ref(null);
const pkce = ref(null);
onMounted(async () => {
pkce.value = await fetch("/api/auth/1pass/challenge", { method: "POST" }).then((r) => r.json());
window.__logiSuccess = (p) => emit("success", p);
window.__logiError = (p) => emit("error", p);
await Promise.resolve();
if (window.LogiWidget && mountRef.value) window.LogiWidget.mountWidget(mountRef.value);
mountRef.value?.addEventListener("logi.cancelled", () => emit("cancelled"));
});
onUnmounted(() => {
delete window.__logiSuccess;
delete window.__logiError;
});
</script>
<template>
<div
v-if="pkce"
ref="mountRef"
data-logi-qr
:data-client-id="clientId"
:data-redirect-uri="redirectUri"
:data-code-challenge="pkce.code_challenge"
:data-state="pkce.state"
data-on-success="__logiSuccess"
data-on-error="__logiError"
/>
</template>Svelte 5
svelte
<script>
import { onMount, onDestroy } from "svelte";
let { clientId, redirectUri, onSuccess, onError, onCancelled } = $props();
let mountEl;
let pkce = $state(null);
onMount(async () => {
pkce = await fetch("/api/auth/1pass/challenge", { method: "POST" }).then((r) => r.json());
window.__logiSuccess = onSuccess;
window.__logiError = onError;
queueMicrotask(() => {
if (window.LogiWidget && mountEl) window.LogiWidget.mountWidget(mountEl);
mountEl?.addEventListener("logi.cancelled", () => onCancelled?.());
});
});
onDestroy(() => {
delete window.__logiSuccess;
delete window.__logiError;
});
</script>
{#if pkce}
<div
bind:this={mountEl}
data-logi-qr
data-client-id={clientId}
data-redirect-uri={redirectUri}
data-code-challenge={pkce.code_challenge}
data-state={pkce.state}
data-on-success="__logiSuccess"
data-on-error="__logiError"
/>
{/if}Vanilla / 기타
위 Quick Start 예시 그대로 HTML 에 박으면 됩니다. UMD 번들이라 모듈 시스템 불필요.
보안 모델
| 위협 | 방어 |
|---|---|
| 등록되지 않은 사이트가 위젯 임베드 | widget_origins allowlist → Content-Security-Policy: frame-ancestors |
| postMessage 도청 / 사칭 | 위젯이 부모로 보낼 때 targetOrigin 제한. 부모는 event.origin === "https://embed.1pass.dev" 검증 필수 |
| 인증 코드 가로채기 | PKCE (code_verifier 는 RP 서버에만 보관, code_challenge 만 위젯에 노출) |
| iframe 내 cookie 탈취 | SameSite=None; Secure cookie + HttpOnly + 별도 서브도메인(embed.1pass.dev) 으로 cookie scope 분리 |
| Clickjacking | logi 측 frame-ancestors 로 등록 origin 만 허용. RP 측은 자체 페이지에 X-Frame-Options 검토 권장 |
| HTTP 임베드 시도 | iframe URL HTTPS only. http origin 거부 (예외: http://localhost:* 개발용) |
client_secret 노출 금지
브라우저 측 코드는 client_secret 을 절대 다루지 않습니다. confidential client 통합에서는 RP 백엔드가 client_secret + code_verifier 를 함께 사용해 토큰 교환. backend 없는 통합은 public client 로 등록하세요.
제한 사항
- 모바일 웹브라우저에서 같은 기기로 자기 화면 QR 을 스캔 불가 → 모바일은 표준 Code Flow 의 Universal Link 점프 권장
- iframe 차단 환경 (일부 기업 방화벽, 광고 차단기) 에서는 위젯이 안 뜸 → fallback 으로 표준 OAuth 버튼 병기 권장
- 현재 v0.x: 디자인 커스터마이징 / 테마 / 언어 토큰 미지원 (v1.x roadmap)
라이브 데모
production walking-sample: https://api.1pass.dev/widget-demo.html. 실제 1pass-demo RP 로 마운트되며 /embed/demo/challenge 가 PKCE 챌린지를 발급합니다.
레퍼런스
- 권장 아키텍처 (RP 통합)
- Troubleshooting — iframe blocked / postMessage 안 옴 / 401 invalid_client 등
- 표준 Authorization Code Flow
- Device Authorization Grant
- 통합 방법 비교
- OAuth 에러 코드
- RFC 7636 — PKCE