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 poll | What the CLI does |
|---|---|
authorization_pending | waits and polls again |
slow_down | waits 5 seconds longer from then on (RFC 8628 §3.5; NanoIDP does not send it today, a CLI should handle it anyway) |
| tokens | done |
access_denied | the user chose Deny: stop |
invalid_grant | the device code is unknown or already used: stop |
expired_token, or its own deadline | the 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 onslow_downwith 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.