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

이 명령은 다음과 같이 완전한 장치 인증 흐름을 시연합니다:

  1. Better Auth 데모 서버에서 장치 코드 요청
  2. 입력할 사용자 코드 표시
  3. 검증 페이지로 브라우저 열기
  4. 인증 완료를 위한 폴링

CLI 로그인 명령은 Better Auth 데모 서버에 연결하여 실제 장치 인증 흐름을 시연하는 데모 기능입니다.

설치

auth 설정에 플러그인 추가하기

서버 설정에 device authorization 플러그인을 추가합니다.

auth.ts
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 migrate
npx @better-auth/cli generate

필드를 수동으로 추가하려면 Schema 섹션을 참조하세요.

클라이언트 플러그인 추가하기

클라이언트에 device authorization 플러그인을 추가합니다.

auth-client.ts
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins"; 

export const authClient = createAuthClient({
  plugins: [ 
    deviceAuthorizationClient(), 
  ], 
});

작동 방식

장치 흐름은 다음 단계를 따릅니다:

  1. 장치가 코드 요청: 장치가 인증 서버에서 장치 코드와 사용자 코드를 요청합니다
  2. 사용자 인증: 사용자가 검증 URL을 방문하고 사용자 코드를 입력합니다
  3. 장치가 토큰 폴링: 사용자가 인증을 완료할 때까지 장치가 서버를 폴링합니다
  4. 접근 허가: 인증되면 장치가 접근 토큰을 받습니다

기본 사용법

장치 인증 요청하기

장치 인증을 시작하려면 클라이언트 ID와 함께 device.code를 호출합니다:

POST
/device/code
const { data, error } = await authClient.device.code({    client_id, // required    scope,});
PropDescriptionType
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}`);
}

토큰 폴링하기

사용자 코드를 표시한 후 접근 토큰을 폴링합니다:

POST
/device/token
const { data, error } = await authClient.device.token({    grant_type, // required    device_code, // required    client_id, // required});
PropDescriptionType
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();

사용자 인증 흐름

사용자 인증 흐름은 두 단계가 필요합니다:

  1. 코드 검증: 입력된 사용자 코드가 유효한지 확인
  2. 인증: 사용자가 장치를 승인/거부하려면 인증되어야 합니다

사용자는 장치 인증 요청을 승인하거나 거부하기 전에 인증되어야 합니다. 인증되지 않은 경우 반환 URL과 함께 로그인 페이지로 리디렉션하세요.

사용자가 코드를 입력할 수 있는 페이지를 만듭니다:

app/device/page.tsx
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>
  );
}

장치 승인 또는 거부하기

사용자는 장치 인증 요청을 승인하거나 거부하려면 인증되어야 합니다:

장치 승인하기

POST
/device/approve
const { data, error } = await authClient.device.approve({    userCode, // required});
PropDescriptionType
userCode
승인할 사용자 코드
string;

장치 거부하기

POST
/device/deny
const { data, error } = await authClient.device.deny({    userCode, // required});
PropDescriptionType
userCode
거부할 사용자 코드
string;

승인 페이지 예시

app/device/approve/page.tsx
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 애플리케이션의 완전한 예시입니다:

cli-auth.ts
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);
});

보안 고려사항

  1. 속도 제한: 플러그인은 남용을 방지하기 위해 폴링 간격을 강제합니다
  2. 코드 만료: 장치 및 사용자 코드는 구성된 시간(기본값: 30분) 후에 만료됩니다
  3. 클라이언트 검증: 무단 접근을 방지하기 위해 프로덕션에서 항상 클라이언트 ID를 검증하세요
  4. HTTPS 전용: 프로덕션에서 장치 인증에는 항상 HTTPS를 사용하세요
  5. 사용자 코드 형식: 사용자 코드는 입력 오류를 줄이기 위해 제한된 문자 집합(0/O, 1/I와 같이 비슷하게 보이는 문자 제외)을 사용합니다
  6. 인증 필수: 사용자는 장치 요청을 승인하거나 거부하기 전에 인증되어야 합니다

옵션

서버

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 NameTypeKeyDescription
idstring장치 인증 요청의 고유 식별자
deviceCodestring-장치 검증 코드
userCodestring-검증을 위한 사용자 친화적 코드
userIdstring승인/거부한 사용자의 ID
clientIdstringOAuth 클라이언트 식별자
scopestring요청된 OAuth 스코프
statusstring-현재 상태: pending, approved 또는 denied
expiresAtDate-장치 코드가 만료되는 시간
lastPolledAtDate장치가 마지막으로 상태를 폴링한 시간
pollingIntervalnumber폴링 사이의 최소 초
createdAtDate-요청이 생성된 시간
updatedAtDate-요청이 마지막으로 업데이트된 시간