Lesson 07 / 23
OAuth 2 Flows
Building authorization delegation with four roles: the authorization code flow run end to end with an authorization server, a resource server, and a client; the rules of the PKCE verifier and the state parameter; the code's single use; and the client credentials and device flows.
Contents
Up to this point, every step of authentication has taken place on a single side: the user gave their password to the loan service, and the loan service issued the token and verified it itself too. This arrangement does not work when a member wants to expose their loan history to a third-party reading app — the application would have to be given the password. And an application holding the password would not only read the loan history; it could do anything the member could do, and access could only be cut off by changing the password.
OAuth 2 solves this problem by delegating authorization. The resource owner gives their credential only to their own authorization server; what the application gets is a token limited in scope and lifetime.
Four Roles
The resource owner is the party that grants authorization — here, member U-1001. The client is the application requesting authorization. The authorization server authenticates the resource owner, obtains their consent, and issues the token. The resource server verifies the token and returns the protected data.
In the authorization code flow, the client does not receive the token directly. The resource owner is redirected to the authorization server, a short-lived authorization code comes back to the client from there, and the client exchanges this code for a token with a separate request that does not pass through the browser. This is why the token never appears in the browser’s address bar.
The Authorization Server
// authorization-server.mjs — authorization code (PKCE), client credentials, and device flows // Usage: node authorization-server.mjs [port] (default 8551) import { createServer } from "node:http"; import { createHmac, createHash, randomBytes, timingSafeEqual } from "node:crypto"; const PORT = Number(process.argv[2] ?? 8551); const ISSUER = `http://127.0.0.1:${PORT}`; const KEY = Buffer.from("identity-provider-signing-key-32bytes!!!", "utf8"); // A "public" client cannot store a secret (browser/mobile); a "confidential" client can. const CLIENTS = new Map([ ["reading-app", { type: "public", redirectUris: ["http://127.0.0.1:8553/callback"], scopes: ["loan:read"] }], ["shelf-terminal", { type: "confidential", secret: "shelf-terminal-secret-1", scopes: ["loan:read", "loan:write"] }], ]); const CODES = new Map(), DEVICES = new Map(); const s256 = (v) => createHash("sha256").update(v).digest("base64url"); const isEqual = (a, b) => { const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8"); return x.length === y.length && timingSafeEqual(x, y); }; const issueAccess = (sub, client, scopes) => { const body = Buffer.from(JSON.stringify({ iss: ISSUER, sub, aud: "loan-service", client_id: client, scope: scopes.join(" "), exp: Math.floor(Date.now() / 1000) + 600 })).toString("base64url"); return `${body}.${createHmac("sha256", KEY).update(body).digest("base64url")}`; }; const readForm = (request) => new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(Object.fromEntries(new URLSearchParams(v)))); }); createServer(async (request, response) => { response.sendDate = false; const url = new URL(request.url, ISSUER), s = url.searchParams; const json = (code, body) => { response.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); }; if (request.method === "GET" && url.pathname === "/authorize") { const client = CLIENTS.get(s.get("client_id") ?? ""), redirect = s.get("redirect_uri") ?? ""; // If the client or redirect address is not recognized, NO REDIRECT HAPPENS; the error stops here. if (!client || !(client.redirectUris ?? []).includes(redirect)) { return json(400, { error: "invalid_request", detail: "client or redirect address not recognized" }); } const returnWithError = (code) => { const u = new URL(redirect); u.searchParams.set("error", code); if (s.get("state")) u.searchParams.set("state", s.get("state")); response.writeHead(302, { location: u.toString() }); response.end(); }; if (s.get("response_type") !== "code" || !s.get("state")) return returnWithError("invalid_request"); if (!s.get("code_challenge") || s.get("code_challenge_method") !== "S256") return returnWithError("invalid_request"); const requested = (s.get("scope") ?? "").split(" ").filter(Boolean); if (!requested.length || !requested.every((k) => client.scopes.includes(k))) return returnWithError("invalid_scope"); // At this point the resource owner is considered to have authenticated and passed the consent screen. const code = randomBytes(24).toString("base64url"); CODES.set(code, { client_id: s.get("client_id"), redirect_uri: redirect, challenge: s.get("code_challenge"), scopes: requested, sub: "U-1001", exp: Date.now() + 60_000, used: false }); const u = new URL(redirect); u.searchParams.set("code", code); u.searchParams.set("state", s.get("state")); response.writeHead(302, { location: u.toString() }); return response.end(); } if (request.method === "POST" && url.pathname === "/device/code") { const form = await readForm(request); const deviceCode = randomBytes(18).toString("base64url"), userCode = randomBytes(3).toString("hex").toUpperCase(); DEVICES.set(deviceCode, { client_id: form.client_id, userCode, approved: false, scopes: (form.scope ?? "").split(" ") }); return json(200, { device_code: deviceCode, user_code: userCode, verification_uri: `${ISSUER}/device`, expires_in: 600, interval: 1 }); } if (request.method === "POST" && url.pathname === "/device/approve") { const form = await readForm(request); for (const [, d] of DEVICES) if (d.userCode === form.user_code) d.approved = true; return json(200, { approved: true }); } if (request.method === "POST" && url.pathname === "/token") { const form = await readForm(request); if (form.grant_type === "authorization_code") { const record = CODES.get(form.code ?? ""); if (!record) return json(400, { error: "invalid_grant", detail: "code not recognized" }); if (record.used) { CODES.delete(form.code); return json(400, { error: "invalid_grant", detail: "code presented a second time" }); } if (Date.now() > record.exp) return json(400, { error: "invalid_grant", detail: "code has expired" }); if (form.client_id !== record.client_id) return json(400, { error: "invalid_grant", detail: "client does not match" }); if (form.redirect_uri !== record.redirect_uri) return json(400, { error: "invalid_grant", detail: "redirect does not match" }); if (!form.code_verifier || !isEqual(s256(form.code_verifier), record.challenge)) { return json(400, { error: "invalid_grant", detail: "verifier does not match" }); } record.used = true; return json(200, { access_token: issueAccess(record.sub, record.client_id, record.scopes), token_type: "Bearer", expires_in: 600, scope: record.scopes.join(" ") }); } if (form.grant_type === "client_credentials") { const [name, secret] = Buffer.from((request.headers.authorization ?? "").replace("Basic ", ""), "base64") .toString("utf8").split(":"); const client = CLIENTS.get(name ?? ""); if (!client || client.type !== "confidential" || !isEqual(secret ?? "", client.secret)) return json(401, { error: "invalid_client" }); const requested = (form.scope ?? "").split(" ").filter(Boolean); if (!requested.every((k) => client.scopes.includes(k))) return json(400, { error: "invalid_scope" }); return json(200, { access_token: issueAccess(name, name, requested), token_type: "Bearer", expires_in: 600, scope: requested.join(" ") }); } if (form.grant_type === "urn:ietf:params:oauth:grant-type:device_code") { const record = DEVICES.get(form.device_code ?? ""); if (!record) return json(400, { error: "invalid_grant" }); if (!record.approved) return json(400, { error: "authorization_pending" }); DEVICES.delete(form.device_code); return json(200, { access_token: issueAccess("U-1001", record.client_id, record.scopes), token_type: "Bearer", expires_in: 600, scope: record.scopes.join(" ") }); } return json(400, { error: "unsupported_grant_type" }); } json(404, { error: "not_found" }); }).listen(PORT, "127.0.0.1", () => console.log(`authorization server ${ISSUER}`));
The order of the checks at the authorization endpoint is not arbitrary. If the client or the redirect address is not recognized, no redirect happens: the error stops in the resource owner’s browser. In a setup where the redirect address is not verified, the error response — or worse, the authorization code — could be sent to an address that is not registered. The redirect address is checked by exact match; prefix matching or a wildcard is not accepted.
The Resource Server
The resource server verifies the token and looks for the scope required by the endpoint.
// resource-server.mjs — loan service: verifies the token and checks scope // Usage: node resource-server.mjs [port] (default 8552) import { createServer } from "node:http"; import { createHmac, timingSafeEqual } from "node:crypto"; const PORT = Number(process.argv[2] ?? 8552); const KEY = Buffer.from("identity-provider-signing-key-32bytes!!!", "utf8"); const REQUIRED = new Map([["GET", "loan:read"], ["POST", "loan:write"]]); const verify = (token) => { const [body, signature] = (token ?? "").split("."); if (!body || !signature) return null; const expected = createHmac("sha256", KEY).update(body).digest("base64url"); const a = Buffer.from(expected, "utf8"), b = Buffer.from(signature, "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); if (claims.iss !== "http://127.0.0.1:8551" || claims.aud !== "loan-service") return null; return Math.floor(Date.now() / 1000) < claims.exp ? claims : null; }; createServer((request, response) => { response.sendDate = false; const json = (code, body) => { response.writeHead(code, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify(body)); }; const header = request.headers.authorization ?? ""; const claims = header.startsWith("Bearer ") ? verify(header.slice(7)) : null; if (!claims) return json(401, { error: "invalid_token" }); const required = request.url.split("?")[0] === "/loans" ? REQUIRED.get(request.method) : undefined; if (!required) return json(404, { error: "not_found" }); if (!claims.scope.split(" ").includes(required)) { return json(403, { error: "insufficient_scope", required, given: claims.scope }); } json(request.method === "GET" ? 200 : 201, { sub: claims.sub, client: claims.client_id, scope: claims.scope }); }).listen(PORT, "127.0.0.1", () => console.log(`resource server http://127.0.0.1:${PORT}`));
The Client
The client generates two values and keeps both of them itself: state and the PKCE
verifier. What is sent to the authorization server is not the verifier itself but
its digest — the challenge.
// client.mjs — reading app: starts the flow, handles the callback, gets the token // Usage: node client.mjs [port] (default 8553) import { createServer } from "node:http"; import { createHash, randomBytes } from "node:crypto"; const PORT = Number(process.argv[2] ?? 8553); const SELF = `http://127.0.0.1:${PORT}`, AS = "http://127.0.0.1:8551", RESOURCE = "http://127.0.0.1:8552"; const PENDING = new Map(); // state -> PKCE verifier; the record lives on the client const s256 = (v) => createHash("sha256").update(v).digest("base64url"); createServer(async (request, response) => { response.sendDate = false; const url = new URL(request.url, SELF); const json = (code, body) => { response.writeHead(code, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify(body)); }; if (url.pathname === "/start") { const state = randomBytes(12).toString("base64url"), verifier = randomBytes(32).toString("base64url"); PENDING.set(state, verifier); const u = new URL(`${AS}/authorize`); for (const [name, value] of Object.entries({ response_type: "code", client_id: "reading-app", redirect_uri: `${SELF}/callback`, scope: "loan:read", state, code_challenge: s256(verifier), code_challenge_method: "S256" })) u.searchParams.set(name, value); return json(200, { authorizationUrl: u.toString() }); } if (url.pathname === "/callback") { const state = url.searchParams.get("state"); // 1. state must match the client's own record; if not, this flow does not belong here. if (!state || !PENDING.has(state)) return json(400, { error: "state did not match" }); const verifier = PENDING.get(state); PENDING.delete(state); // state is single-use if (url.searchParams.get("error")) return json(400, { error: url.searchParams.get("error") }); // 2. The code is exchanged for a token together with the verifier. const tokenResponse = await fetch(`${AS}/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code"), redirect_uri: `${SELF}/callback`, client_id: "reading-app", code_verifier: verifier }), }); const token = await tokenResponse.json(); if (!tokenResponse.ok) return json(400, { error: token }); // 3. The resource server is called with the token. const resource = await fetch(`${RESOURCE}/loans`, { headers: { authorization: `Bearer ${token.access_token}` } }); return json(200, { scope: token.scope, resourceStatus: resource.status, resourceBody: await resource.json() }); } json(404, { error: "no such resource" }); }).listen(PORT, "127.0.0.1", () => console.log(`client ${SELF}`));
The two values’ functions are separate, and both are mandatory.
state verifies that the returning request belongs to a flow the client started
itself. The client generates the state value, keeps it, and on the way back checks
whether it is on record; a callback that does not match is the result of a flow the
client did not start, and is rejected. A client that does not check state can bind
the code of an externally started flow to its own user’s session. This is why the
setup must be rejected if state is not generated or not checked.
The PKCE verifier binds the authorization code to the client that requested it.
The authorization server stores the code together with the challenge; on the token
request, the verifier’s digest is compared against this challenge. Because the
verifier never leaves the client’s memory, a party that gets hold of the code from
somewhere else has no piece in hand to exchange it for a token. For public clients
that cannot store a secret, this is the code’s only protection; this is why the
request must be rejected if code_challenge is missing or the method is not S256.
Running the Flow End to End
Three servers are brought up, and curl plays the browser’s role: it gets the
authorization address from the client, goes to that address, and follows the returned
Location header.
node authorization-server.mjs > /dev/null 2>&1 & Y=$! node resource-server.mjs > /dev/null 2>&1 & K=$! node client.mjs > /dev/null 2>&1 & I=$! sleep 1.2 ADDR=$(curl -s http://127.0.0.1:8553/start | sed -n 's/.*"authorizationUrl":"\([^"]*\)".*/\1/p') echo "1) authorization address:"; echo " $ADDR" BACK=$(curl -s -o /dev/null -w '%{redirect_url}' "$ADDR") echo "2) callback address:"; echo " $BACK" echo "3) client's result:"; curl -s "$BACK"; echo kill $Y $K $I
1) authorization address:
http://127.0.0.1:8551/authorize?response_type=code&client_id=reading-app&redirect_uri=http%3A%2F%2F127.0.0.1%3A8553%2Fcallback&scope=loan%3Aread&state=rGkCVLl6FMBezD1T&code_challenge=JtgCbusArRl8pHEGWyMBpV6P_TaFfjCEzQsAoKiAyM8&code_challenge_method=S256
2) callback address:
http://127.0.0.1:8553/callback?code=7ICTKjl1Ro-fpP8DjsD0c9yzOUu-ADWy&state=rGkCVLl6FMBezD1T
3) client's result:
{"scope":"loan:read","resourceStatus":200,"resourceBody":{"sub":"U-1001","client":"reading-app","scope":"loan:read"}}
state, the challenge, and the code change on every run. The second line shows that
the only thing passing through the browser is the code; the token appears at no
address. In the resource server’s response, sub states the member and client
states the application separately: the resource server can distinguish on whose
behalf a request comes from which application it comes through.
Checks at the Token Endpoint
The block below tries the code directly at the token endpoint; the verifier and the challenge are generated in the shell.
node authorization-server.mjs > /dev/null 2>&1 & Y=$! sleep 1 AS=http://127.0.0.1:8551 CALLBACK=http://127.0.0.1:8553/callback VERIFIER=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=') CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=') getCode() { curl -s -o /dev/null -w '%{redirect_url}' \ "$AS/authorize?response_type=code&client_id=reading-app&redirect_uri=$CALLBACK&scope=loan:read&state=abc&code_challenge=$CHALLENGE&code_challenge_method=S256" \ | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p' } exchange() { curl -s -d "$1" "$AS/token" | sed 's/"access_token":"[^"]*"/"access_token":"…"/'; } CODE=$(getCode) echo "valid exchange : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app&code_verifier=$VERIFIER")" echo "same code twice : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app&code_verifier=$VERIFIER")" CODE=$(getCode) echo "verifier missing : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app")" CODE=$(getCode) echo "another redirect : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=http://127.0.0.1:8553/other&client_id=reading-app&code_verifier=$VERIFIER")" kill $Y
valid exchange : {"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read"}
same code twice : {"error":"invalid_grant","detail":"code presented a second time"}
verifier missing : {"error":"invalid_grant","detail":"verifier does not match"}
another redirect : {"error":"invalid_grant","detail":"redirect does not match"}
The code is single-use, and on its second presentation the record is dropped entirely — the same reuse detection built for refresh tokens. A missing verifier and a wrong verifier being presented get the same response; making a distinction would serve no purpose other than leaking information to the outside.
Two More Flows
The client credentials flow is for the case where there is no resource owner: the shelf terminal acts on its own behalf, proves its identity with its own secret, and its scope comes from its own registration. The device flow is for devices that cannot open a browser: the device gets a user code, the user approves that code on another device, and the device polls in the meantime.
node authorization-server.mjs > /dev/null 2>&1 & Y=$! node resource-server.mjs > /dev/null 2>&1 & K=$! sleep 1 AS=http://127.0.0.1:8551 shorten() { sed 's/"access_token":"[^"]*"/"access_token":"…"/'; } RESP=$(curl -s -u shelf-terminal:shelf-terminal-secret-1 -d 'grant_type=client_credentials&scope=loan:read loan:write' $AS/token) echo "$RESP" | shorten TOKEN=$(echo "$RESP" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') echo "POST /loans: $(curl -s -X POST -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8552/loans)" READONLY=$(curl -s -u shelf-terminal:shelf-terminal-secret-1 -d 'grant_type=client_credentials&scope=loan:read' $AS/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') echo "POST /loans with read-only scope: $(curl -s -X POST -H "Authorization: Bearer $READONLY" http://127.0.0.1:8552/loans)" DEVICE=$(curl -s -d 'client_id=shelf-terminal&scope=loan:read' $AS/device/code) echo "$DEVICE" DEVICE_CODE=$(echo "$DEVICE" | sed -n 's/.*"device_code":"\([^"]*\)".*/\1/p') USER_CODE=$(echo "$DEVICE" | sed -n 's/.*"user_code":"\([^"]*\)".*/\1/p') echo "poll before approval: $(curl -s -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$DEVICE_CODE" $AS/token)" curl -s -o /dev/null -d "user_code=$USER_CODE" $AS/device/approve echo "poll after approval : $(curl -s -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$DEVICE_CODE" $AS/token | shorten)" kill $Y $K
{"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read loan:write"}
POST /loans: {"sub":"shelf-terminal","client":"shelf-terminal","scope":"loan:read loan:write"}
POST /loans with read-only scope: {"error":"insufficient_scope","required":"loan:write","given":"loan:read"}
{"device_code":"6kPpAgjAdv9XmiYLCEoGtVUW","user_code":"1DA15F","verification_uri":"http://127.0.0.1:8551/device","expires_in":600,"interval":1}
poll before approval: {"error":"authorization_pending"}
poll after approval : {"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read"}
The device and user codes change on every run. The third line shows what scope does:
the same client, with a token it obtained with only the read scope, gets a 403 at the
write endpoint. Scope narrows not who the token belongs to, but what it is allowed
to do. In the device flow, polling gets authorization_pending until approval
arrives; the device must not poll more often than the interval value stated in the
response.
Summary
- Authorization delegation defines four roles: resource owner, client, authorization server, and resource server. The resource owner gives their credential only to the authorization server.
- In the authorization code flow, only the short-lived code passes through the browser; the token is obtained with a separate request that does not pass through the browser.
- The redirect address is checked by exact match, and nothing is sent to an
unrecognized address; the setup must be rejected if
stateis not generated or checked, ifcode_challengeis missing, or if the method is notS256. - The authorization code is single-use; on its second presentation the record is dropped, and a mismatched or missing verifier gets the same response.
- The client credentials flow is for machine calls with no resource owner; the device flow moves approval to another device for devices that cannot open a browser.
- Scope narrows what the token is allowed to do; the resource server looks for the scope required by the endpoint and returns a 403 when it is missing.
Next Step
None of these flows answered the question “who is this user?” The resource server
read the sub field in the token, but what that field means, which user it
corresponds to, and whether the token was really issued as the result of
authentication all fall outside the scope of the authorization delegation protocol. If
the client needs to recognize the user — to display their name, match their account,
open their session — a separate layer is required. The next lesson builds this layer:
the authorization server gives the client an identity token alongside the access
token, and the client verifies it with issuer, audience, duration, and signature
steps.
To keep your progress and take notes, Log in
My notes
Log in to take notes.