OAUTH 2.0 · LAB
Build a command-line login with a temporary loopback listener
Write a small CLI that signs in through the browser on an ephemeral loopback port, stores its refresh token for your account only, runs automation as its own client and revokes on logout.
Partly readyUses your lab tenant
The lesson
Builds on: Client credentials.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.
- G5 Device authorization grant
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Register the CLI as a public native client
Recorded as
tenant.oauth.clients.createsucceeded.Sign in from the CLI through the browser
Recorded as
oauth.tokensucceeded forlab-tmp-cliabout[email protected].Run automation as its own client
Recorded as
oauth.tokensucceeded forlab-print-orders.Log out by revoking the refresh token
Recorded as
oauth.revokesucceeded (refresh_token_found) forlab-tmp-cli.See an escaped copy of the old refresh token refused
Recorded as
oauth.tokenrejected (refresh_revoked) forlab-tmp-cli.
Request console
Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.
Setup
Choose Lab Photos as the lab tenant and press Start.
In Clients, create
lab-tmp-clifrom the Native or desktop application preset: Public, PKCE required, authorization code and refresh token, redirect URIhttp://127.0.0.1:8765/callback. The tenant matches loopback redirect URIs exactly except for the port, so the CLI may use any port at runtime. Flow policy must allow the refresh token grant.Set the variables and make a working folder outside any repository:
export ISSUER="https://tenant-<id>.beyondthelogin.dev" CLI_ID="<lab-tmp-cli client ID>"
mkdir -p ~/lab-cli && cd ~/lab-cli
Save
photos-cli.mjs(Node 18 or later, no dependencies):
// photos-cli.mjs: login, whoami and logout for a public client. It never handles a password or a secret.
import http from 'node:http'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
const { ISSUER, CLI_ID } = process.env;
const FILE = path.join(os.homedir(), '.config', 'photos-cli', 'credentials.json');
const meta = await (await fetch(`${ISSUER}/.well-known/oauth-authorization-server`)).json();
if (meta.issuer !== ISSUER) throw new Error('metadata issuer does not match configuration');
const b64 = bytes => bytes.toString('base64url');
const post = async (url, params) => {
const r = await fetch(url, { method: 'POST', body: new URLSearchParams({ client_id: CLI_ID, ...params }) });
const text = await r.text(); return { status: r.status, body: text ? JSON.parse(text) : {} };
};
const save = tokens => { fs.mkdirSync(path.dirname(FILE), { recursive: true, mode: 0o700 }); fs.writeFileSync(FILE, JSON.stringify(tokens), { mode: 0o600 }); };
const load = () => JSON.parse(fs.readFileSync(FILE, 'utf8'));
const command = process.argv[2];
if (command === 'login') {
const verifier = b64(crypto.randomBytes(32)), state = b64(crypto.randomBytes(16));
const challenge = b64(crypto.createHash('sha256').update(verifier).digest());
const server = http.createServer();
// Port 0 asks the operating system for a free port. Bind the literal loopback address only.
await new Promise(resolve => server.listen(0, process.env.CLI_HOST ?? '127.0.0.1', resolve));
const redirect = `http://127.0.0.1:${server.address().port}/callback`;
console.log('Open this address to sign in:\n' + meta.authorization_endpoint + '?' + new URLSearchParams({ response_type: 'code', client_id: CLI_ID,
redirect_uri: redirect, scope: 'openid photos.read offline_access', state, code_challenge: challenge, code_challenge_method: 'S256' }));
const params = await new Promise(resolve => server.on('request', (req, res) => {
const url = new URL(req.url, redirect);
if (url.pathname !== '/callback') { res.writeHead(404); return res.end(); }
res.end('You can close this window.'); server.close(); resolve(url.searchParams);
}));
if (params.get('state') !== state || params.get('iss') !== ISSUER) throw new Error('The response does not match this sign-in.');
if (params.get('error')) throw new Error(`Sign-in refused: ${params.get('error')}`);
const { status, body } = await post(meta.token_endpoint, { grant_type: 'authorization_code', code: params.get('code'), redirect_uri: redirect, code_verifier: verifier });
if (status !== 200) throw new Error(`Exchange refused: ${body.error}`);
save(body); console.log(`Signed in. Tokens saved in ${FILE}`);
} else if (command === 'whoami') {
let tokens = load(); const me = t => fetch(meta.userinfo_endpoint, { headers: { Authorization: `Bearer ${t.access_token}` } });
let r = await me(tokens);
if (r.status === 401) {
const { status, body } = await post(meta.token_endpoint, { grant_type: 'refresh_token', refresh_token: tokens.refresh_token });
if (status !== 200) throw new Error(`Refresh refused (${body.error}). Run login again.`);
save(tokens = { ...tokens, ...body }); r = await me(tokens); // save the replacement before using it
}
console.log(r.status, await r.json());
} else if (command === 'logout') {
const { status } = await post(meta.revocation_endpoint, { token: load().refresh_token, token_type_hint: 'refresh_token' });
if (status !== 200) throw new Error('Revocation failed. The credentials are kept so you can try again.');
fs.rmSync(FILE); console.log('Signed out: refresh token revoked, then deleted.');
} else console.log('usage: node photos-cli.mjs login | whoami | logout');
Walkthrough
Sign in:
node photos-cli.mjs login. Note the port in the printedredirect_uri, open the address, and sign in as Ava. The browser shows "You can close this window" and the terminal says it saved the tokens.
Why it matters: a listener on the literal loopback address, only for the length of sign-in, receives the code without exposing a port to the network. The tool is a public client, so the verifier protects the exchange.
Use it:
node photos-cli.mjs whoamiprints200and Ava's profile.
Inspect where the tokens live:
ls -l ~/.config/photos-cli/credentials.jsonshows-rw-------.
Why it matters: a file readable only by your account stops other people on the machine, but not other programs running as you, and it travels into backups. On a desktop, the operating system's credential store is the better home.
Keep a copy of the refresh token as an escaped copy would exist in a backup:
OLD=$(jq -r .refresh_token ~/.config/photos-cli/credentials.json).
Run automation as itself, not as Ava. Use
lab-print-orderswith client credentials, and keep its secret out of the command line and the process list by passing it to curl on standard input:
read -rs ORDERS_SECRET; ORDERS_ID="<lab-print-orders client ID>"
printf 'user = "%s:%s"\n' "$ORDERS_ID" "$ORDERS_SECRET" | curl -s -K - "$ISSUER/oauth/token" -d grant_type=client_credentials -d scope=prints.create | jq 'del(.access_token)'
Why it matters: a nightly job that belongs to an organization should have its own registration, reviewable and removable without touching anyone's account. A secret given as an argument would land in shell history and in the process list.
Check whether the device grant is available for a machine with no browser:
POST$ISSUER/oauth/device_authorization
Open in console
POST $ISSUER/oauth/device_authorization HTTP/1.1
Content-Type: application/x-www-form-urlencoded
client_id=$CLI_ID&scope=photos.readReturns 501. Discovery lists the endpoint with btl_endpoint_status not_implemented (G5).
Log out, then try the escaped copy:
node photos-cli.mjs logout
node photos-cli.mjs whoami # fails: the credentials file is gone
curl -s "$ISSUER/oauth/token" -d grant_type=refresh_token -d "client_id=$CLI_ID" --data-urlencode "refresh_token=$OLD" | jq .
The last call returns invalid_grant, Audit reason refresh_revoked.
Why it matters: revoking before deleting means a copy that escaped earlier, into a backup or a log, stops working too.
Break it
Start the listener on every interface instead of loopback: CLI_HOST=0.0.0.0 node photos-cli.mjs login. While it waits, run netstat -an | grep LISTEN (or ss -ltn): the port is open on 0.0.0.0, reachable from the network. Press Ctrl+C without signing in.
Restore: run unset CLI_HOST so the CLI binds 127.0.0.1 again.
Check your work
Press Check my progress before Cleanup, because Cleanup deletes the client. The checks look for, in order: the creation of lab-tmp-cli, its code exchange for Ava, a client credentials token for lab-print-orders, oauth.revoke with refresh_token_found for lab-tmp-cli, and the escaped copy refused with refresh_revoked.
The 501 from the device endpoint appears only in Logs, as oauth.device rejected with not_implemented.
Cleanup
Delete the credentials file if it remains, and the folder:
rm -rf ~/.config/photos-cli ~/lab-cli. Rununset OLD ORDERS_SECRET.Delete the client
lab-tmp-cli.
Missing infrastructure
G5 Device authorization grant. Once it exists, the CLI gains
login --device: it prints a verification address and user code, polls the token endpoint, and the learner approves on a phone. The lab then comparesauthorization_pendingandslow_downresponses with the loopback flow and repeats the lesson's warning to enter only a code you just saw printed by a tool you started.