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.
Sign in to start this lab and check your progress. Log in or create an account.
Sign Ava in from the page
Recorded as
oauth.authorizesucceeded (code_issued) forlab-printer-app.Redeem the code from the page with PKCE
Recorded as
oauth.tokensucceeded forlab-printer-appabout[email protected].Read Ava's profile from the page
Recorded as
oidc.userinfosucceeded (userinfo_served) forlab-printer-app.Give the browser client an eight-hour refresh limit
Recorded as
tenant.oauth.managers.assignsucceeded.Refresh from the page under the new limit
Recorded as
oauth.tokensucceeded forlab-printer-appabout[email protected].
Setup
Choose Lab Photos as the lab tenant and press Start.
In Clients, confirm
lab-printer-appis Public with PKCE required, allows the authorization code and refresh token grants, and has the redirect URIhttp://127.0.0.1:8765/callback. Add that URI if it is missing. Flow policy must allow the refresh token grant.Make a working folder outside any repository:
mkdir -p ~/lab-editor && cd ~/lab-editor.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>
Save
app.js, replacing the two placeholders with your issuer andlab-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}`));
})();
Save
serve.mjs, a static server that sends a Content Security Policy header, and start it withISSUER=$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
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.
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 carriesAccess-Control-Allow-Origin: http://127.0.0.1:8765andCache-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.
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 foroffline_access.
Why it matters: the authorization endpoint is never read cross-origin. The token endpoint is the only place the page reads tokens from.
Compare storage choices. In the console, run
sessionStorage.getItem('pending'):null, because the callback removed the pending attempt. The tokens are in variables insideapp.js. Now changetoken()to also runlocalStorage.setItem('at', accessToken), reload, sign in again, then open a second tab onhttp://127.0.0.1:8765/and runlocalStorage.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.
Cap refresh tokens for the browser client. In Access Token Management, create
lab-tmp-browser(Signed JWT, Maximum lifetime600, Sign-in limit for refresh tokens in seconds28800, Refresh token reuse grace0) 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.
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
Stop
serve.mjs.Assign Default access tokens back to
lab-printer-app, then deletelab-tmp-browser.Delete the folder:
rm -rf ~/lab-editor.