Device Authorization
RFC 8628 CLI Smart TV IoT
Device Authorization 플러그인은 OAuth 2.0 Device Authorization Grant(RFC 8628)를 구현하여 스마트 TV, CLI 애플리케이션, IoT 장치 및 게임 콘솔과 같이 입력 기능이 제한된 장치에 대한 인증을 가능하게 합니다.
직접 사용해보기
Better Auth CLI를 사용하여 지금 바로 장치 인증 흐름을 테스트할 수 있습니다:
npx @better-auth/cli login이 명령은 다음과 같이 완전한 장치 인증 흐름을 시연합니다:
- Better Auth 데모 서버에서 장치 코드 요청
- 입력할 사용자 코드 표시
- 검증 페이지로 브라우저 열기
- 인증 완료를 위한 폴링
CLI 로그인 명령은 Better Auth 데모 서버에 연결하여 실제 장치 인증 흐름을 시연하는 데모 기능입니다.
설치
auth 설정에 플러그인 추가하기
서버 설정에 device authorization 플러그인을 추가합니다.
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins";
export const auth = betterAuth({
// ... 기타 설정
plugins: [
deviceAuthorization({
// 선택적 설정
expiresIn: "30m", // 장치 코드 만료 시간
interval: "5s", // 최소 폴링 간격
}),
],
});데이터베이스 마이그레이션
마이그레이션을 실행하거나 스키마를 생성하여 필요한 테이블을 데이터베이스에 추가합니다.
npx @better-auth/cli migratenpx @better-auth/cli generate필드를 수동으로 추가하려면 Schema 섹션을 참조하세요.
클라이언트 플러그인 추가하기
클라이언트에 device authorization 플러그인을 추가합니다.
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
deviceAuthorizationClient(),
],
});작동 방식
장치 흐름은 다음 단계를 따릅니다:
- 장치가 코드 요청: 장치가 인증 서버에서 장치 코드와 사용자 코드를 요청합니다
- 사용자 인증: 사용자가 검증 URL을 방문하고 사용자 코드를 입력합니다
- 장치가 토큰 폴링: 사용자가 인증을 완료할 때까지 장치가 서버를 폴링합니다
- 접근 허가: 인증되면 장치가 접근 토큰을 받습니다
기본 사용법
장치 인증 요청하기
장치 인증을 시작하려면 클라이언트 ID와 함께 device.code를 호출합니다:
const { data, error } = await authClient.device.code({ client_id, // required scope,});| Prop | Description | Type |
|---|---|---|
client_id | OAuth 클라이언트 식별자 | string; |
scope? | 공백으로 구분된 요청된 스코프 목록 (선택 사항) | string; |
사용 예시:
const { data } = await authClient.device.code({
client_id: "your-client-id",
scope: "openid profile email",
});
if (data) {
console.log(`다음 주소를 방문하세요: ${data.verification_uri}`);
console.log(`코드를 입력하세요: ${data.user_code}`);
}토큰 폴링하기
사용자 코드를 표시한 후 접근 토큰을 폴링합니다:
const { data, error } = await authClient.device.token({ grant_type, // required device_code, // required client_id, // required});| Prop | Description | Type |
|---|---|---|
grant_type | "urn:ietf:params:oauth:grant-type:device_code"여야 합니다 | string; |
device_code | 초기 요청의 장치 코드 | string; |
client_id | OAuth 클라이언트 식별자 | string; |
폴링 구현 예시:
let pollingInterval = 5; // 5초로 시작
const pollForToken = async () => {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: yourClientId,
fetchOptions: {
headers: {
"user-agent": `My CLI`,
},
},
});
if (data?.access_token) {
console.log("인증 성공!");
} else if (error) {
switch (error.error) {
case "authorization_pending":
// 폴링 계속
break;
case "slow_down":
pollingInterval += 5;
break;
case "access_denied":
console.error("사용자가 접근을 거부했습니다");
return;
case "expired_token":
console.error("장치 코드가 만료되었습니다. 다시 시도하세요.");
return;
default:
console.error(`오류: ${error.error_description}`);
return;
}
setTimeout(pollForToken, pollingInterval * 1000);
}
};
pollForToken();사용자 인증 흐름
사용자 인증 흐름은 두 단계가 필요합니다:
- 코드 검증: 입력된 사용자 코드가 유효한지 확인
- 인증: 사용자가 장치를 승인/거부하려면 인증되어야 합니다
사용자는 장치 인증 요청을 승인하거나 거부하기 전에 인증되어야 합니다. 인증되지 않은 경우 반환 URL과 함께 로그인 페이지로 리디렉션하세요.
사용자가 코드를 입력할 수 있는 페이지를 만듭니다:
export default function DeviceAuthorizationPage() {
const [userCode, setUserCode] = useState("");
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
try {
// 코드 형식 지정: 대시 제거 및 대문자로 변환
const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
// GET /device 엔드포인트를 사용하여 코드가 유효한지 확인
const response = await authClient.device({
query: { user_code: formattedCode },
});
if (response.data) {
// 승인 페이지로 리디렉션
window.location.href = `/device/approve?user_code=${formattedCode}`;
}
} catch (err) {
setError("유효하지 않거나 만료된 코드입니다");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="장치 코드를 입력하세요 (예: ABCD-1234)"
maxLength={12}
/>
<button type="submit">계속</button>
{error && <p>{error}</p>}
</form>
);
}장치 승인 또는 거부하기
사용자는 장치 인증 요청을 승인하거나 거부하려면 인증되어야 합니다:
장치 승인하기
const { data, error } = await authClient.device.approve({ userCode, // required});| Prop | Description | Type |
|---|---|---|
userCode | 승인할 사용자 코드 | string; |
장치 거부하기
const { data, error } = await authClient.device.deny({ userCode, // required});| Prop | Description | Type |
|---|---|---|
userCode | 거부할 사용자 코드 | string; |
승인 페이지 예시
export default function DeviceApprovalPage() {
const { user } = useAuth(); // 인증되어야 합니다
const searchParams = useSearchParams();
const userCode = searchParams.get("userCode");
const [isProcessing, setIsProcessing] = useState(false);
const handleApprove = async () => {
setIsProcessing(true);
try {
await authClient.device.approve({
userCode: userCode,
});
// 성공 메시지 표시
alert("장치가 성공적으로 승인되었습니다!");
window.location.href = "/";
} catch (error) {
alert("장치 승인에 실패했습니다");
}
setIsProcessing(false);
};
const handleDeny = async () => {
setIsProcessing(true);
try {
await authClient.device.deny({
userCode: userCode,
});
alert("장치가 거부되었습니다");
window.location.href = "/";
} catch (error) {
alert("장치 거부에 실패했습니다");
}
setIsProcessing(false);
};
if (!user) {
// 인증되지 않은 경우 로그인으로 리디렉션
window.location.href = `/login?redirect=/device/approve?user_code=${userCode}`;
return null;
}
return (
<div>
<h2>장치 인증 요청</h2>
<p>장치가 귀하의 계정에 대한 접근을 요청하고 있습니다.</p>
<p>코드: {userCode}</p>
<button onClick={handleApprove} disabled={isProcessing}>
승인
</button>
<button onClick={handleDeny} disabled={isProcessing}>
거부
</button>
</div>
);
}고급 설정
클라이언트 검증
클라이언트 ID를 검증하여 승인된 애플리케이션만 장치 흐름을 사용할 수 있도록 할 수 있습니다:
deviceAuthorization({
validateClient: async (clientId) => {
// 클라이언트가 승인되었는지 확인
const client = await db.oauth_clients.findOne({ id: clientId });
return client && client.allowDeviceFlow;
},
onDeviceAuthRequest: async (clientId, scope) => {
// 장치 인증 요청 로깅
await logDeviceAuthRequest(clientId, scope);
},
})사용자 정의 코드 생성
장치 및 사용자 코드 생성 방식을 사용자 정의합니다:
deviceAuthorization({
generateDeviceCode: async () => {
// 사용자 정의 장치 코드 생성
return crypto.randomBytes(32).toString("hex");
},
generateUserCode: async () => {
// 사용자 정의 사용자 코드 생성
// 기본값 사용: ABCDEFGHJKLMNPQRSTUVWXYZ23456789
// (혼란을 피하기 위해 0, O, 1, I 제외)
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 8; i++) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
},
})오류 처리
장치 흐름은 특정 오류 코드를 정의합니다:
| 오류 코드 | 설명 |
|---|---|
authorization_pending | 사용자가 아직 승인하지 않음 (폴링 계속) |
slow_down | 폴링이 너무 빈번함 (간격 증가) |
expired_token | 장치 코드가 만료됨 |
access_denied | 사용자가 인증을 거부함 |
invalid_grant | 유효하지 않은 장치 코드 또는 클라이언트 ID |
예시: CLI 애플리케이션
실제 데모를 기반으로 한 CLI 애플리케이션의 완전한 예시입니다:
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import open from "open";
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [deviceAuthorizationClient()],
});
async function authenticateCLI() {
console.log("🔐 Better Auth Device Authorization 데모");
console.log("⏳ 장치 인증을 요청하는 중...");
try {
// 장치 코드 요청
const { data, error } = await authClient.device.code({
client_id: "demo-cli",
scope: "openid profile email",
});
if (error || !data) {
console.error("❌ 오류:", error?.error_description);
process.exit(1);
}
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
interval = 5,
} = data;
console.log("\n📱 장치 인증 진행 중");
console.log(`다음 주소를 방문하세요: ${verification_uri}`);
console.log(`코드를 입력하세요: ${user_code}\n`);
// 완전한 URL로 브라우저 열기
const urlToOpen = verification_uri_complete || verification_uri;
if (urlToOpen) {
console.log("🌐 브라우저를 여는 중...");
await open(urlToOpen);
}
console.log(`⏳ 인증을 기다리는 중... (${interval}초마다 폴링)`);
// 토큰 폴링
await pollForToken(device_code, interval);
} catch (err) {
console.error("❌ 오류:", err.message);
process.exit(1);
}
}
async function pollForToken(deviceCode: string, interval: number) {
let pollingInterval = interval;
return new Promise<void>((resolve) => {
const poll = async () => {
try {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: "demo-cli",
});
if (data?.access_token) {
console.log("\n인증 성공!");
console.log("접근 토큰을 받았습니다!");
// 사용자 세션 가져오기
const { data: session } = await authClient.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
},
});
console.log(`안녕하세요, ${session?.user?.name || "사용자"}님!`);
resolve();
process.exit(0);
} else if (error) {
switch (error.error) {
case "authorization_pending":
// 조용히 폴링 계속
break;
case "slow_down":
pollingInterval += 5;
console.log(`⚠️ 폴링을 ${pollingInterval}초로 늦춥니다`);
break;
case "access_denied":
console.error("❌ 사용자가 접근을 거부했습니다");
process.exit(1);
break;
case "expired_token":
console.error("❌ 장치 코드가 만료되었습니다. 다시 시도하세요.");
process.exit(1);
break;
default:
console.error("❌ 오류:", error.error_description);
process.exit(1);
}
}
} catch (err) {
console.error("❌ 네트워크 오류:", err.message);
process.exit(1);
}
// 다음 폴링 예약
setTimeout(poll, pollingInterval * 1000);
};
// 폴링 시작
setTimeout(poll, pollingInterval * 1000);
});
}
// 인증 흐름 실행
authenticateCLI().catch((err) => {
console.error("❌ 치명적 오류:", err);
process.exit(1);
});보안 고려사항
- 속도 제한: 플러그인은 남용을 방지하기 위해 폴링 간격을 강제합니다
- 코드 만료: 장치 및 사용자 코드는 구성된 시간(기본값: 30분) 후에 만료됩니다
- 클라이언트 검증: 무단 접근을 방지하기 위해 프로덕션에서 항상 클라이언트 ID를 검증하세요
- HTTPS 전용: 프로덕션에서 장치 인증에는 항상 HTTPS를 사용하세요
- 사용자 코드 형식: 사용자 코드는 입력 오류를 줄이기 위해 제한된 문자 집합(0/O, 1/I와 같이 비슷하게 보이는 문자 제외)을 사용합니다
- 인증 필수: 사용자는 장치 요청을 승인하거나 거부하기 전에 인증되어야 합니다
옵션
서버
expiresIn: 장치 코드의 만료 시간입니다. 기본값: "30m" (30분).
interval: 최소 폴링 간격입니다. 기본값: "5s" (5초).
userCodeLength: 사용자 코드의 길이입니다. 기본값: 8.
deviceCodeLength: 장치 코드의 길이입니다. 기본값: 40.
generateDeviceCode: 장치 코드를 생성하는 사용자 정의 함수입니다. 문자열 또는 Promise<string>을 반환합니다.
generateUserCode: 사용자 코드를 생성하는 사용자 정의 함수입니다. 문자열 또는 Promise<string>을 반환합니다.
validateClient: 클라이언트 ID를 검증하는 함수입니다. clientId를 받아 boolean 또는 Promise<boolean>을 반환합니다.
onDeviceAuthRequest: 장치 인증이 요청될 때 호출되는 훅입니다. clientId와 선택적 scope를 받습니다.
클라이언트
클라이언트별 설정 옵션은 없습니다. 플러그인은 다음 메서드를 추가합니다:
- device(): 사용자 코드 유효성 검증
- device.code(): 장치 및 사용자 코드 요청
- device.token(): 접근 토큰 폴링
- device.approve(): 장치 승인 (인증 필요)
- device.deny(): 장치 거부 (인증 필요)
스키마
플러그인은 장치 인증 데이터를 저장하기 위한 새 테이블이 필요합니다.
테이블 이름: deviceCode
| Field Name | Type | Key | Description |
|---|---|---|---|
| id | string | 장치 인증 요청의 고유 식별자 | |
| deviceCode | string | - | 장치 검증 코드 |
| userCode | string | - | 검증을 위한 사용자 친화적 코드 |
| userId | string | 승인/거부한 사용자의 ID | |
| clientId | string | OAuth 클라이언트 식별자 | |
| scope | string | 요청된 OAuth 스코프 | |
| status | string | - | 현재 상태: pending, approved 또는 denied |
| expiresAt | Date | - | 장치 코드가 만료되는 시간 |
| lastPolledAt | Date | 장치가 마지막으로 상태를 폴링한 시간 | |
| pollingInterval | number | 폴링 사이의 최소 초 | |
| createdAt | Date | - | 요청이 생성된 시간 |
| updatedAt | Date | - | 요청이 마지막으로 업데이트된 시간 |