Skip to content

Device Authorization Grant (RFC 8628)

RFC 8628 — OAuth 2.0 Device Authorization Grant 기반의 device flow. RP 가 자체 페이지에서 QR 을 띄우고 폴링으로 토큰을 수령합니다. 사용자는 logi 앱으로 QR 을 스캔해 승인하면 RP 페이지가 자동으로 다음 단계로 진행됩니다.

언제 쓰나요?

  • RP 페이지에서 사용자가 떠나지 않아야 할 때 (네이버 패턴, 결제 직전, 모달 안에서 SSO)
  • 브라우저가 없는 환경 (CLI, Smart TV, IoT 디바이스)
  • iOS Universal Link 가 작동하지 않는 인앱 브라우저 (카카오톡 / 네이버 인앱)

Public client 도 device flow 사용 가능

RFC 8628 §3.4 — public 과 confidential 모두 device flow 지원. Public RP 는 client_secret 없이 client_id 만으로 device authorization 시작 가능.

페이지 redirect 가 허용되는 일반 웹 RP 라면 표준 Authorization Code Flow 가 더 단순합니다. UI 통제가 필요 없고 빠른 통합이 목표면 Widget SDK 를 보세요.

시퀀스 다이어그램

mermaid
sequenceDiagram
  autonumber
  participant U as 사용자
  participant RP as RP 페이지
  participant L as logi 서버 (api.1pass.dev)
  participant App as logi 모바일 앱

  RP->>L: POST /oauth/device_authorization (client_id, scope)
  L-->>RP: { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }
  RP->>RP: QR 렌더 (verification_uri_complete) + interval 초마다 폴링 시작

  loop polling (interval=5s 권장)
    RP->>L: POST /oauth/token (grant_type=device_code, device_code)
    L-->>RP: 400 { error: "authorization_pending" }
  end

  U->>App: QR 스캔
  App->>L: 사용자 인증 + scope 동의
  L->>L: device_code → approved 상태 전이

  RP->>L: POST /oauth/token (grant_type=device_code, device_code)
  L-->>RP: 200 { access_token, refresh_token, id_token?, token_type, expires_in }
  RP->>U: 로그인 완료 (페이지 전환 없음)

엔드포인트

1. Device Authorization 요청

POST https://api.1pass.dev/oauth/device_authorization
Content-Type: application/x-www-form-urlencoded
파라미터필수설명
client_idRP 의 OAuth Application client_id
client_secretconfidential 만confidential client 만 필요 (Basic auth 또는 form). public client 는 미사용 — 보내면 거절 (downgrade 방어)
scope공백 구분 scope 목록 (예: openid profile email)

응답 (200 OK):

json
{
  "device_code": "x7Y9aZ-...",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://api.1pass.dev/activate",
  "verification_uri_complete": "https://api.1pass.dev/activate?user_code=WDJB-MJHT",
  "expires_in": 600,
  "interval": 5
}
필드의미
device_codeRP 가 이후 폴링에 사용할 비밀 토큰. 사용자에게 노출 금지
user_code사용자가 직접 입력해야 할 때 표시할 짧은 코드 (QR 사용 시 노출 불필요)
verification_uri사용자가 브라우저로 직접 방문할 짧은 URL
verification_uri_completeuser_code 가 prefill 된 풀 URL — QR 인코딩에 이 값을 사용
expires_indevice_code 의 유효 기간 (초). 기본 600초 (10분)
interval최소 폴링 간격 (초). 기본 5초. slow_down 응답 시 +5초 추가

Cache 헤더

응답에는 Cache-Control: no-store, Pragma: no-cache 가 자동 부여됩니다 (RFC 6749 §5.1).

2. 토큰 폴링

POST https://api.1pass.dev/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
파라미터필수설명
grant_type정확히 urn:ietf:params:oauth:grant-type:device_code
device_code1단계에서 받은 device_code
client_id(Basic 헤더 미사용 시) form body 에 포함

성공 응답 (200 OK):

json
{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "8aZk9...urlsafe_base64_32B_no_prefix...",
  "scope": "openid profile email"
}

scopeopenid 가 포함됐다면 응답에 id_token 도 함께 반환됩니다. 토큰 검증은 JWKS 참고.

폴링 에러 코드

device flow 의 모든 에러는 400 Bad Request 또는 401 Unauthorized 로 반환되며, body 는 { "error": "...", "error_description": "..." } 형식입니다.

error의미RP 의 액션
authorization_pending사용자가 아직 승인 안 함interval 초 후 다시 폴링
slow_down폴링이 너무 빠름interval 을 +5초 늘려서 다시 폴링
access_denied사용자가 거절폴링 중단, 사용자에게 안내. 새 device flow 재시작 필요
expired_tokendevice_code 만료 (10분 초과)폴링 중단, /oauth/device_authorization 부터 재시작
invalid_grantdevice_code 가 무효 / 다른 client / 이미 교환됨폴링 중단, 새로 시작
invalid_clientclient_id / client_secret 인증 실패자격 증명 확인
unauthorized_clientRP 가 device flow 미승인logi 콘솔에서 application 승인 상태 확인

Polling cadence — slow_down (HTTP 400)

RFC 8628 §3.5 의 표준 폴링 cadence 제어입니다. RP 가 응답받은 interval 보다 빠르게 /oauth/token 을 폴링하면 logi 서버는 device_code 단위로 즉시 slow_down 을 내려보냅니다.

필드
HTTP status400 Bad Request
errorslow_down
error_descriptionpolling too fast; respect the interval value
Retry-After 헤더❌ 내려가지 않음

RP 처리: RFC 8628 §3.5 에 따라 현재 interval+5초 누적 증가시키고 다음 폴링까지 대기. 같은 device flow 안에서 한 번 늘린 값은 reset 하지 마세요.

javascript
// 폴링 루프 안에서 slow_down 처리
const { error } = await res.json();
if (error === "slow_down") {
  pollInterval += 5000;   // 누적 증가, reset 금지
  continue;
}

Rate limit — rate_limited (HTTP 429)

logi 서버는 /oauth/device_authorization/oauth/token 양쪽 엔드포인트에 controller 레벨 rate limiter 를 두고 있습니다. client_id (없으면 IP) 기준 한도를 초과하면 device_code 폴링 cadence 와는 별개로 429 가 떨어집니다.

필드
HTTP status429 Too Many Requests
errorrate_limited
error_descriptiontoo many token requests (token endpoint) / too many device authorization requests (device_authorization endpoint)
Retry-After 헤더❌ 현재 미설정
적용 한도/oauth/token 20 req/min, /oauth/device_authorization 30 req/min (모두 client_id 또는 IP 기준)

RP 처리: Retry-After 헤더가 없으므로 exponential backoff 으로 후퇴하세요.

  1. 첫 429: 현재 interval × 2 만큼 대기 후 1회 재시도.
  2. 연속 3회 이상 429 가 반복되면 폴링 중단, 사용자에게 안내 (RP 자격증명/네트워크 점검 신호).
  3. /oauth/device_authorization 단계에서 429 가 나면 새 device flow 를 시작하기 전 최소 60초 backoff. 무한 재시도 금지.
javascript
// 폴링 루프 안에서 429 처리 (Retry-After 없음 → 자체 backoff)
if (res.status === 429) {
  pollInterval = Math.min(pollInterval * 2, 60_000);
  consecutive429 += 1;
  if (consecutive429 >= 3) throw new Error("rate_limited");
  continue;
}

코드 예시

curl

bash
# 1. Device authorization 요청
curl -X POST https://api.1pass.dev/oauth/device_authorization \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "scope=openid profile email"

# → device_code, user_code, verification_uri_complete 수령

# 2. (사용자가 QR 스캔하는 동안) 폴링
curl -X POST https://api.1pass.dev/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  -d "device_code=$DEVICE_CODE"

# → authorization_pending → ... → access_token

JavaScript (브라우저)

javascript
async function deviceLogin(clientId) {
  // 1. Device authorization 요청 — 클라이언트 시크릿이 없는 브라우저는
  // 일반적으로 RP 백엔드를 경유합니다 (아래는 예시):
  const init = await fetch("/api/auth/1pass/device/start", { method: "POST" });
  const { device_code, user_code, verification_uri_complete, interval, expires_in } = await init.json();

  // 2. QR 코드 렌더 (verification_uri_complete)
  renderQR(verification_uri_complete);
  showUserCode(user_code); // QR 못 찍는 사용자용 fallback

  // 3. 폴링 루프
  const deadline = Date.now() + expires_in * 1000;
  let pollInterval = interval * 1000;

  while (Date.now() < deadline) {
    await sleep(pollInterval);

    const res = await fetch("/api/auth/1pass/device/poll", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ device_code }),
    });

    if (res.ok) {
      const { access_token, id_token } = await res.json();
      return { access_token, id_token };
    }

    const { error } = await res.json();
    if (error === "slow_down") pollInterval += 5000;
    else if (error === "authorization_pending") continue;
    else throw new Error(error); // access_denied / expired_token / ...
  }
  throw new Error("device_code expired");
}

Ruby (Rails RP 백엔드)

ruby
# 1. Device authorization 요청 (RP 백엔드 → logi)
require "net/http"
require "json"

def start_device_flow
  uri = URI("https://api.1pass.dev/oauth/device_authorization")
  req = Net::HTTP::Post.new(uri)
  req.basic_auth(ENV["LOGI_CLIENT_ID"], ENV["LOGI_CLIENT_SECRET"])
  req.set_form_data(scope: "openid profile email")

  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
  JSON.parse(res.body)
end

# 2. 폴링 (브라우저가 호출하는 RP 엔드포인트)
def poll_device_flow(device_code)
  uri = URI("https://api.1pass.dev/oauth/token")
  req = Net::HTTP::Post.new(uri)
  req.basic_auth(ENV["LOGI_CLIENT_ID"], ENV["LOGI_CLIENT_SECRET"])
  req.set_form_data(
    grant_type: "urn:ietf:params:oauth:grant-type:device_code",
    device_code: device_code
  )

  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
  { status: res.code.to_i, body: JSON.parse(res.body) }
end

보안 가이드

  • HTTPS only. 모든 device flow 트래픽은 TLS 위에서만 동작합니다.
  • client_secret 보호 (confidential). confidential RP 는 client_secret 을 RP 백엔드에서만 보관하세요. 브라우저나 모바일 앱 바이너리에 포함시키지 마세요.
  • Public client 도 device flow 사용 가능 (RFC 8628 §3.4). Public RP 는 client_id 만으로 device authorization 시작 — client_secret 보내면 logi 가 invalid_client 거절 (downgrade 방어).
  • device_code 는 비밀. 사용자에게 표시하지 마세요. QR 에는 verification_uri_complete 만 인코딩.
  • interval 준수. slow_down 응답 시 +5초씩 늘려야 rate limit 에 걸리지 않습니다 (/oauth/device_authorizationclient_id / IP 기준 30 req/min).
  • state 는 device flow 에 없음. CSRF 보호는 device_code 자체의 비밀성과 1회성으로 보장됩니다 — RP 는 자기 사용자 세션과 device_code 를 서버 측에서 묶어두세요.

레퍼런스

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