Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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.

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.

  1. Register the CLI as a public native client

    Recorded as tenant.oauth.clients.create succeeded.

  2. Sign in from the CLI through the browser

    Recorded as oauth.token succeeded for lab-tmp-cli about [email protected].

  3. Run automation as its own client

    Recorded as oauth.token succeeded for lab-print-orders.

  4. Log out by revoking the refresh token

    Recorded as oauth.revoke succeeded (refresh_token_found) for lab-tmp-cli.

  5. See an escaped copy of the old refresh token refused

    Recorded as oauth.token rejected (refresh_revoked) for lab-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

  1. Choose Lab Photos as the lab tenant and press Start.

  2. In Clients, create lab-tmp-cli from the Native or desktop application preset: Public, PKCE required, authorization code and refresh token, redirect URI http://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.

  3. 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
  1. 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

  1. Sign in: node photos-cli.mjs login. Note the port in the printed redirect_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.

  1. Use it: node photos-cli.mjs whoami prints 200 and Ava's profile.

  1. Inspect where the tokens live: ls -l ~/.config/photos-cli/credentials.json shows -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.

  1. 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).

  1. Run automation as itself, not as Ava. Use lab-print-orders with 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.

  1. 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.read

Returns 501. Discovery lists the endpoint with btl_endpoint_status not_implemented (G5).

  1. 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

  1. Delete the credentials file if it remains, and the folder: rm -rf ~/.config/photos-cli ~/lab-cli. Run unset OLD ORDERS_SECRET.

  2. 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 compares authorization_pending and slow_down responses with the loopback flow and repeats the lesson's warning to enter only a code you just saw printed by a tool you started.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab