The standard OAuth 2.0 authorization code flow assumes the device initiating the flow can open a browser and handle a redirect. For command-line tools, smart TVs, game consoles, and headless IoT devices, this assumption doesn't hold. RFC 8628 — the OAuth 2.0 Device Authorization Grant — solves exactly this problem. It's the flow behind "Visit example.com/activate and enter code WXYZ-PQRS" on your TV screen, and it powers gh auth login and similar CLI authentication flows.
How the device flow works
The flow has two parallel legs:
Device leg: The constrained device registers with the auth server, receives a device code and user code, displays the user code, and polls the token endpoint until authorization completes or the codes expire.
User leg: The user visits a "device activation" URL on any capable device (phone, laptop), enters the user code, authenticates, and approves the authorization.
The two legs complete asynchronously. The device is polling; the user is authenticating independently.
Authorization server implementation
import { randomBytes } from 'crypto';
// Step 1: Device requests authorization
router.post('/oauth/device/code', async (req, res) => {
const { client_id, scope } = req.body;
// Validate client
const client = await db.oauthClients.findById(client_id);
if (!client || !client.deviceFlowEnabled) {
return res.status(400).json({ error: 'unauthorized_client' });
}
// Generate device_code (high entropy, not user-visible)
const deviceCode = randomBytes(32).toString('base64url');
// Generate user_code (human-typeable: 8 chars, unambiguous alphabet)
const userCode = generateUserCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.deviceAuthorizations.create({
deviceCode,
userCode,
clientId: client_id,
scope,
expiresAt,
status: 'pending',
interval: 5, // seconds between polls
});
res.json({
device_code: deviceCode,
user_code: userCode,
verification_uri: 'https://app.bastionary.com/device',
verification_uri_complete: `https://app.bastionary.com/device?user_code=${userCode}`,
expires_in: 900,
interval: 5,
});
});
// User code format: 8 chars from an unambiguous alphabet, split into two groups
function generateUserCode(): string {
// Avoid ambiguous chars: 0/O, 1/I/L
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
let code = '';
const bytes = randomBytes(8);
for (const byte of bytes) {
code += ALPHABET[byte % ALPHABET.length];
}
return `${code.slice(0, 4)}-${code.slice(4)}`; // WXYZ-PQRS format
}
The polling endpoint
// Step 2: Device polls this endpoint
router.post('/oauth/token', async (req, res) => {
const { grant_type, device_code, client_id } = req.body;
if (grant_type !== 'urn:ietf:params:oauth:grant-type:device_code') return next();
const auth = await db.deviceAuthorizations.findByDeviceCode(device_code);
if (!auth || auth.clientId !== client_id) {
return res.status(400).json({ error: 'invalid_grant' });
}
if (new Date() > auth.expiresAt) {
return res.status(400).json({ error: 'expired_token' });
}
// Enforce polling interval to prevent hammering
const now = Date.now();
const lastPoll = auth.lastPolledAt?.getTime() || 0;
if (now - lastPoll < (auth.interval * 1000) - 500) { // 500ms tolerance
await db.deviceAuthorizations.update(auth.id, {
lastPolledAt: new Date(),
interval: auth.interval + 5, // slow down on too-fast polling
});
return res.status(400).json({ error: 'slow_down' });
}
await db.deviceAuthorizations.update(auth.id, { lastPolledAt: new Date() });
switch (auth.status) {
case 'pending':
return res.status(400).json({ error: 'authorization_pending' });
case 'denied':
return res.status(400).json({ error: 'access_denied' });
case 'approved': {
// Issue tokens
const tokens = await issueTokensForUser(auth.userId!, auth.scope, auth.clientId);
await db.deviceAuthorizations.update(auth.id, { status: 'consumed' });
return res.json({
access_token: tokens.accessToken,
token_type: 'Bearer',
expires_in: 900,
refresh_token: tokens.refreshToken,
scope: auth.scope,
});
}
default:
return res.status(400).json({ error: 'invalid_grant' });
}
});
The user activation page
// User visits /device, enters or sees pre-filled user_code, and approves
router.get('/device', (req, res) => {
res.render('device-activate', {
prefilledCode: req.query.user_code || '',
});
});
router.post('/device/activate', requireAuth, async (req, res) => {
const { user_code } = req.body;
const normalized = user_code.toUpperCase().replace(/[^A-Z0-9]/g, '');
const auth = await db.deviceAuthorizations.findByUserCode(
`${normalized.slice(0, 4)}-${normalized.slice(4)}`
);
if (!auth || auth.status !== 'pending' || new Date() > auth.expiresAt) {
return res.render('device-activate', {
error: 'Invalid or expired code. Please try again.',
});
}
// Show consent screen with requested scopes
const client = await db.oauthClients.findById(auth.clientId);
res.render('device-consent', {
auth,
client,
scopes: auth.scope.split(' '),
});
});
router.post('/device/approve', requireAuth, async (req, res) => {
const { auth_id, action } = req.body;
const auth = await db.deviceAuthorizations.findById(auth_id);
await db.deviceAuthorizations.update(auth.id, {
status: action === 'approve' ? 'approved' : 'denied',
userId: req.user.id,
});
res.render('device-complete', {
approved: action === 'approve',
clientName: auth.clientName,
});
});
CLI integration (client side)
// bastionary-cli login command
async function deviceLogin(baseUrl: string): Promise<void> {
// Step 1: Request device code
const codeResponse = await fetch(`${baseUrl}/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: 'bastionary-cli',
scope: 'read:profile write:profile',
}),
}).then(r => r.json());
console.log(`\nTo sign in, visit: ${codeResponse.verification_uri}`);
console.log(`Enter code: ${codeResponse.user_code}`);
console.log(`\nWaiting for authorization...`);
// Optionally open browser automatically
// await open(codeResponse.verification_uri_complete);
// Step 2: Poll for completion
const deadline = Date.now() + codeResponse.expires_in * 1000;
let interval = codeResponse.interval * 1000;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, interval));
const tokenResponse = await fetch(`${baseUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: codeResponse.device_code,
client_id: 'bastionary-cli',
}),
});
const data = await tokenResponse.json();
if (tokenResponse.ok) {
await storeTokens(data); // save to ~/.config/bastionary/credentials
console.log('Logged in successfully.');
return;
}
if (data.error === 'slow_down') interval += 5000;
else if (data.error === 'authorization_pending') continue;
else if (data.error === 'access_denied') {
console.error('Authorization denied.');
process.exit(1);
} else if (data.error === 'expired_token') {
console.error('Code expired. Run login again.');
process.exit(1);
}
}
console.error('Timed out waiting for authorization.');
process.exit(1);
}
verification_uri_complete URL — most phones can scan it directly to jump to the activation page without typing the URL. This cuts activation time from ~30 seconds to ~5 seconds.