Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Test a CLI login with the device authorization flow

A command-line tool, a smart TV or a build agent cannot show a login page. The OAuth 2.0 device authorization grant (RFC 8628) solves that: the tool asks for a short code, tells the user to enter it in a browser anywhere, and polls until the user has logged in and approved. The GitHub CLI logs in this way, and so can several cloud CLIs.

Testing that loop needs an authorization server that issues device codes, serves the page where the user enters them, and answers the polling correctly: pending, denied, used once. NanoIDP does all of it locally, and the browser step can be scripted, so the whole login runs in a test.

Everything here is in examples/cli-device-flow/, and the repository's CI runs the tests below on every change.

1. Register the CLI as a public client

A CLI ships to its users, so a secret inside it is not a secret. Register it as a public client, identified by its client_id alone:

pip install nanoidp requests pytest   # the CLI uses requests; pytest runs the tests
mkdir -p config
base=https://raw.githubusercontent.com/cdelmonte-zg/nanoidp/main/examples/cli-device-flow
curl -fsSL -o config/settings.yaml "$base/settings.yaml"
curl -fsSL -o config/users.yaml "$base/users.yaml"
python -m nanoidp --config ./config
# NanoIDP Settings - CLI / Device Flow Integration
# Copy this file to your config directory
# Guide: https://cdelmonte-zg.github.io/nanoidp/use-cases/cli-device-flow.html

server:
  host: "127.0.0.1"
  # host: "0.0.0.0"  # LAN access is intentionally opt-in (GHSA-2473-px8h-rvg6)
  port: 8000

oauth:
  issuer: "http://localhost:8000"
  audience: "cli-devices"
  token_expiry_minutes: 60
  clients:
    # A CLI ships to its users, so it cannot keep a secret: a public client,
    # identified by client_id alone. Its refresh tokens always rotate
    - client_id: "cli-tool"
      token_endpoint_auth_method: "none"
      description: "Command-line interface tool"

# Device codes last 600 seconds and clients are told to poll every 5
# seconds. A device_flow section changes both (code_expiry_seconds,
# polling_interval) since 3.4.0; 3.3.0 and earlier ignore it

# Authority prefix mapping
authority_prefixes:
  roles: "ROLE_"
  identity_class: "IDENTITY_"
  entitlements: "ENT_"

# Allowed identity classes
allowed_identity_classes:
  - "INTERNAL"
  - "EXTERNAL"

The users are admin / admin (roles ADMIN, USER) and user / user (role USER).

2. The CLI side

"""Log a command-line tool in with the device authorization grant (RFC 8628)."""
import time

import requests

IDP = "http://localhost:8000"
CLIENT_ID = "cli-tool"  # a public client: no secret
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
TIMEOUT = 10  # seconds, per HTTP request


class LoginFailed(Exception):
    pass


def request_device_code(scope="openid profile offline_access"):
    response = requests.post(f"{IDP}/device_authorization",
                             data={"client_id": CLIENT_ID, "scope": scope},
                             timeout=TIMEOUT)
    response.raise_for_status()
    return response.json()


def poll_for_token(device, sleep=time.sleep):
    """Poll /token until the user decides, the code expires, or it fails."""
    interval = device.get("interval", 5)
    deadline = time.monotonic() + device["expires_in"]
    while time.monotonic() < deadline:
        sleep(interval)
        answer = requests.post(f"{IDP}/token", timeout=TIMEOUT, data={
            "grant_type": DEVICE_GRANT,
            "client_id": CLIENT_ID,
            "device_code": device["device_code"],
        }).json()
        error = answer.get("error")
        if error is None:
            return answer
        if error == "authorization_pending":
            continue
        if error == "slow_down":  # RFC 8628 §3.5: wait 5 seconds longer
            interval += 5
            continue
        raise LoginFailed(error)  # access_denied, expired_token, invalid_grant...
    raise LoginFailed("expired_token")


def refresh(refresh_token):
    """Exchange a refresh token. It rotates: store the new one, drop the old."""
    response = requests.post(f"{IDP}/token", timeout=TIMEOUT, data={
        "grant_type": "refresh_token",
        "client_id": CLIENT_ID,
        "refresh_token": refresh_token,
    })
    answer = response.json()
    if "error" in answer:
        raise LoginFailed(answer["error"])
    return answer


if __name__ == "__main__":
    device = request_device_code()
    print(f"Open {device['verification_uri']} and enter the code {device['user_code']}")
    print(f"(or open {device['verification_uri_complete']})")
    tokens = poll_for_token(device)
    print("Logged in; access token expires in", tokens["expires_in"], "seconds")
$ python cli_login.py
Open http://localhost:8000/device and enter the code H3V74VHV
(or open http://localhost:8000/device?user_code=H3V74VHV)
Logged in; access token expires in 3600 seconds

The user opens the URL and enters the code (with verification_uri_complete it is already filled in), logs in and chooses Authorize Device or Deny. Meanwhile the CLI polls /token every interval seconds:

NanoIDP's answer to a pollWhat the CLI does
authorization_pendingwaits and polls again
slow_downwaits 5 seconds longer from then on (RFC 8628 §3.5; NanoIDP does not send it today, a CLI should handle it anyway)
tokensdone
access_deniedthe user chose Deny: stop
invalid_grantthe device code is unknown or already used: stop
expired_token, or its own deadlinethe code's lifetime is over: start again

The access token is the user's: its sub is the user who approved, with that user's roles, and its client_id is cli-tool.

A device code lives 600 seconds, and clients are told to poll every 5. In 3.4.0 and later both can be set, which makes expired_token testable in a second:

device_flow:
  code_expiry_seconds: 1   # default 600
  polling_interval: 1      # default 5

3.3.0 and earlier accept that section and ignore it.

3. Script the browser step and test the loop

What the user does in the browser is one form post to /device, with the user code, the credentials and the button pressed. The tests run the CLI's own polling in a thread, and approve or deny while it waits:

"""The CLI's device flow against NanoIDP, with the browser step scripted.

Needs NanoIDP on :8000 with this preset.
"""
import threading
import time

import jwt
import pytest
import requests
from cli_login import IDP, TIMEOUT, LoginFailed, poll_for_token, refresh, request_device_code


def user_decides(user_code, username, password, action="authorize"):
    """What the user does in the browser at verification_uri."""
    return requests.post(f"{IDP}/device", timeout=TIMEOUT, data={
        "user_code": user_code, "username": username, "password": password,
        "action": action,
    })


def poll_in_background(device):
    """Start polling as the CLI does, and return a way to collect the result.

    Returns once the CLI has polled at least once and gone back to waiting,
    so the user's decision always finds it mid-poll, as in real use.
    """
    outcome = {}
    pauses = threading.Semaphore(0)

    def pause(seconds):
        # Tests do not wait out the polling interval, only a moment
        pauses.release()
        time.sleep(0.05)

    def run():
        try:
            outcome["tokens"] = poll_for_token(device, sleep=pause)
        except LoginFailed as failure:
            outcome["error"] = str(failure)

    thread = threading.Thread(target=run, daemon=True)  # a stuck CLI fails the test, not the run
    thread.start()
    # One pause before the first poll, one after its authorization_pending
    assert pauses.acquire(timeout=10) and pauses.acquire(timeout=10)
    return lambda: (thread.join(timeout=30), outcome)[1]


def test_the_user_approves_and_the_cli_gets_tokens_for_that_user():
    device = request_device_code()
    result = poll_in_background(device)
    assert user_decides(device["user_code"], "user", "user").status_code == 200
    tokens = result()["tokens"]
    claims = jwt.decode(tokens["access_token"], options={"verify_signature": False})
    assert claims["sub"] == "user"
    assert claims["client_id"] == "cli-tool"
    assert claims["roles"] == ["USER"]
    assert "refresh_token" in tokens


def test_the_user_denies():
    device = request_device_code()
    result = poll_in_background(device)
    user_decides(device["user_code"], "user", "user", action="deny")
    assert result()["error"] == "access_denied"


def test_a_wrong_password_leaves_the_request_pending():
    device = request_device_code()
    page = user_decides(device["user_code"], "user", "wrong")
    assert "Invalid username or password" in page.text
    # The CLI keeps polling; the request is still open for a correct login
    pending = requests.post(f"{IDP}/token", timeout=TIMEOUT, data={
        "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
        "client_id": "cli-tool", "device_code": device["device_code"],
    }).json()
    assert pending["error"] == "authorization_pending"


def test_an_unknown_user_code_is_refused():
    page = user_decides("ZZZZZZZZ", "user", "user")
    assert "Invalid or expired user code" in page.text


def test_a_device_code_gives_tokens_once():
    device = request_device_code()
    result = poll_in_background(device)
    user_decides(device["user_code"], "user", "user")
    result()
    # A short deadline, so a CLI that retried every error would fail here
    # within seconds instead of polling for the code's full lifetime
    with pytest.raises(LoginFailed, match="invalid_grant"):
        poll_for_token(dict(device, expires_in=3), sleep=lambda seconds: time.sleep(0.05))


def test_refresh_tokens_rotate_and_a_reused_one_revokes_the_rest():
    device = request_device_code()
    result = poll_in_background(device)
    user_decides(device["user_code"], "user", "user")
    first = result()["tokens"]["refresh_token"]

    second = refresh(first)["refresh_token"]
    assert second != first
    # Using the old one again is treated as theft: it fails, and it revokes
    # the newest one too, so the CLI must log in again
    with pytest.raises(LoginFailed, match="invalid_grant"):
        refresh(first)
    with pytest.raises(LoginFailed, match="invalid_grant"):
        refresh(second)

The form fields (user_code, username, password, action) belong to NanoIDP's verification page; the device code request and the polling are RFC 8628 and look the same against any authorization server.

Refresh tokens rotate

A CLI keeps its refresh token between runs. For a public client NanoIDP always rotates it: each refresh answers a new refresh token, and the old one stops working. Using an old one again is treated as a stolen token: it fails, and it also revokes the newest one, so the CLI has to log in again. The revocation covers the refresh tokens of that one login: access tokens already issued stay valid until they expire, and other logins of the same user are not affected. A CLI must therefore write the new refresh token to disk after every refresh, before anything else can fail. The last test pins that behaviour.

Takeaways

  • Register a CLI as a public client (token_endpoint_auth_method: "none"): it cannot keep a secret.
  • Poll at the interval the server gives. Keep polling on authorization_pending, and on slow_down with 5 seconds more between polls; stop on every other error.
  • A device code gives tokens once.
  • Store the rotated refresh token every time. Reusing an old one revokes that login's refresh tokens, the newest included, so the CLI has to log in again.
  • The browser step is one form post, so the whole device login is a test that runs in CI.