Skip to content
academia.sh

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 state is not generated or checked, if code_challenge is missing, or if the method is not S256.
  • 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.

Start typing to search.

↑↓ Esc navigate · open · close