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

Run a browser-only public client from 127.0.0.1 and see where tokens live

Build a one-page public client with PKCE, read the CORS answer that lets it see tokens, compare storage choices, cap its refresh tokens at eight hours and test its CSP.

ReadyUses your lab tenant

The lesson

Builds on: Public and confidential clients.

New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.

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. Sign Ava in from the page

    Recorded as oauth.authorize succeeded (code_issued) for lab-printer-app.

  2. Redeem the code from the page with PKCE

    Recorded as oauth.token succeeded for lab-printer-app about [email protected].

  3. Read Ava's profile from the page

    Recorded as oidc.userinfo succeeded (userinfo_served) for lab-printer-app.

  4. Give the browser client an eight-hour refresh limit

    Recorded as tenant.oauth.managers.assign succeeded.

  5. Refresh from the page under the new limit

    Recorded as oauth.token succeeded for lab-printer-app about [email protected].

Setup

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

  2. In Clients, confirm lab-printer-app is Public with PKCE required, allows the authorization code and refresh token grants, and has the redirect URI http://127.0.0.1:8765/callback. Add that URI if it is missing. Flow policy must allow the refresh token grant.

  3. Make a working folder outside any repository: mkdir -p ~/lab-editor && cd ~/lab-editor.

  4. Save index.html:

<!doctype html><meta charset="utf-8"><title>Lab editor</title>
<button id="go">Open from photo account</button> <button id="refresh">Refresh</button>
<pre id="out"></pre>
<script src="/app.js"></script>
  1. Save app.js, replacing the two placeholders with your issuer and lab-printer-app's client ID:

const ISSUER = 'https://tenant-<id>.beyondthelogin.dev', CLIENT_ID = '<lab-printer-app client ID>';
const REDIRECT = 'http://127.0.0.1:8765/callback';
const b64url = buf => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const show = value => { document.getElementById('out').textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2); };
let accessToken = null, refreshToken = null;   // memory only: gone when the tab reloads
async function token(params) {
  const r = await fetch(`${ISSUER}/oauth/token`, { method: 'POST', body: new URLSearchParams({ client_id: CLIENT_ID, ...params }) });
  const body = await r.json(); if (!r.ok) throw new Error(body.error);
  accessToken = body.access_token; refreshToken = body.refresh_token ?? refreshToken;
  const me = await fetch(`${ISSUER}/oidc/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
  show({ granted: body.scope, expires_in: body.expires_in, userinfo: await me.json() });
}
document.getElementById('go').onclick = async () => {
  const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))), state = b64url(crypto.getRandomValues(new Uint8Array(16)));
  const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
  sessionStorage.setItem('pending', JSON.stringify({ state, verifier }));
  location.assign(`${ISSUER}/oauth/authorize?` + new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT,
    scope: 'openid photos.read offline_access', state, code_challenge: challenge, code_challenge_method: 'S256' }));
};
document.getElementById('refresh').onclick = () => token({ grant_type: 'refresh_token', refresh_token: refreshToken }).catch(e => show(`Refresh refused: ${e.message}`));
if (location.pathname === '/callback') (async () => {
  const p = new URLSearchParams(location.search), pending = JSON.parse(sessionStorage.getItem('pending') ?? 'null');
  sessionStorage.removeItem('pending'); history.replaceState(null, '', '/');
  if (!pending || p.get('state') !== pending.state || p.get('iss') !== ISSUER) return show('Refused: this response does not match an attempt started in this tab.');
  if (p.get('error')) return show(`Authorization refused: ${p.get('error')}`);
  await token({ grant_type: 'authorization_code', code: p.get('code'), redirect_uri: REDIRECT, code_verifier: pending.verifier }).catch(e => show(`Exchange refused: ${e.message}`));
})();
  1. Save serve.mjs, a static server that sends a Content Security Policy header, and start it with ISSUER=$ISSUER node serve.mjs:

import http from 'node:http'; import fs from 'node:fs';
const csp = `script-src 'self'; connect-src ${process.env.ISSUER}`;
http.createServer((req, res) => {
  const js = req.url === '/app.js';
  res.writeHead(200, { 'Content-Type': js ? 'text/javascript' : 'text/html', 'Content-Security-Policy': csp });
  res.end(fs.readFileSync(js ? 'app.js' : 'index.html'));
}).listen(8765, '127.0.0.1', () => console.log('editor on http://127.0.0.1:8765/'));

Walkthrough

  1. Open http://127.0.0.1:8765/, select Open from photo account, sign in as Ava and approve. The callback page shows the granted scope and Ava's UserInfo.

Why it matters: the whole client runs in the page. There is no secret anywhere in it, because anyone can read the page's JavaScript; PKCE binds the code to this attempt instead.

  1. In developer tools, Network, open the token request. The request carries Origin: http://127.0.0.1:8765, which the browser set and the page cannot change. The response carries Access-Control-Allow-Origin: http://127.0.0.1:8765 and Cache-Control: no-store.

Why it matters: the same-origin policy would hide this response from the page. The tenant allows only the origins of the public client's registered redirect URIs to read token responses.

  1. Note that the authorization request was a navigation of the whole tab, not a fetch, and the response includes a refresh token because the page asked for offline_access.

Why it matters: the authorization endpoint is never read cross-origin. The token endpoint is the only place the page reads tokens from.

  1. Compare storage choices. In the console, run sessionStorage.getItem('pending'): null, because the callback removed the pending attempt. The tokens are in variables inside app.js. Now change token() to also run localStorage.setItem('at', accessToken), reload, sign in again, then open a second tab on http://127.0.0.1:8765/ and run localStorage.getItem('at') in its console. The token is there, in a tab that never signed in.

Why it matters: any script running on the origin reads local or session storage with one line, in every tab, long after use. Memory is harder to reach, though injected script can still call the functions that use it.

Restore: remove the localStorage.setItem line, run localStorage.clear() in the console, and reload.

  1. Cap refresh tokens for the browser client. In Access Token Management, create lab-tmp-browser (Signed JWT, Maximum lifetime 600, Sign-in limit for refresh tokens in seconds 28800, Refresh token reuse grace 0) and Assign a client: lab-printer-app. In the page, select Refresh: it succeeds and rotates. The family now ends eight hours after Ava connected, however often the page refreshes.

Why it matters: a refresh token in a browser must rotate and must stop at a fixed maximum, because rotation notices theft only when both the stolen and the legitimate copies are used.

  1. See origins compared exactly. Open http://localhost:8765/ instead, and in its console run:

fetch(ISSUER + '/oauth/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'x', client_id: CLIENT_ID }) }).catch(e => console.log('blocked:', e.message))

The browser blocks the response: the tenant does not allow http://localhost:8765 to read it. Signing in from this origin also fails, because the callback lands on 127.0.0.1, where this tab's pending attempt does not exist.

Why it matters: an origin is scheme, host and port, compared exactly. localhost and 127.0.0.1 are different origins.

Break it

Add an inline script to index.html, for example <script>document.title = 'inline ran'</script>, and reload. The browser refuses to run it and reports a Content Security Policy violation in the console, because the policy allows scripts only from the page's own origin.

Restore: remove the inline script.

Note: the policy is defense in depth. It does nothing against script that arrives from an allowed source, which can use the page's tokens while it is open. The next lab moves the tokens out of the browser.

Check your work

Press Check my progress. The checks look for, in order: oauth.authorize with code_issued for lab-printer-app, its code exchange for Ava, oidc.userinfo for it, the assignment of lab-tmp-browser, and a refresh after it.

Audit shows the public client's events with no authenticated actor: a client ID sent without a secret is a claim, not proof.

Cleanup

  1. Stop serve.mjs.

  2. Assign Default access tokens back to lab-printer-app, then delete lab-tmp-browser.

  3. Delete the folder: rm -rf ~/lab-editor.

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