Sessions and Tokens — From the Browser to the Mesh
Keeping tokens out of the browser with a BFF
Goal
Bring up the issuer (8312), the downstream API (8311), and the BFF (8310) in one Pod, and prove with real HTTP requests that the access token never appears anywhere the browser can see and that only calls that go through the BFF work downstream.
Why it matters
If an SPA holds the token itself, a single compromised script running on that page lets the token leak out, and the attacker uses that token until it expires. The BFF of section 6.1 of RFC 10017 (OAuth 2.0 for Browser-Based Applications) keeps the token only in the server-side session and gives the browser only an HttpOnly session cookie. As a confidential client, the BFF exchanges the code for a token, and when passing downstream it strips the cookie and attaches the Bearer token. This structure collapses if even one place is loose — if the session check response carries the token, if downstream accepts a request with no token, or if logout does not revoke the token, the BFF is a BFF in name only. This lab makes you check each of those three gaps with requests.
Steps
- With the single file
/root/st/bff/app.py, bring up the issuer (127.0.0.1:8312), the downstream API (127.0.0.1:8311), and the BFF (127.0.0.1:8310). All three return 200 and{"ok":true}forGET /health. - Log in in the order
GET /login, then the issuerPOST /authorize, then the BFF/callback, and save the oneSet-Cookieline that the callback handed down in/root/st/bff/cookie.txt. The cookie has the name__Host-Http-bffand hasHttpOnly,Secure,SameSite=Strict, andPath=/. - Write into
/root/st/bff/exposure.txtwhetherbff:sess:<세션ID>in Redis hasaccess_token(the placeholder is the session ID) and that value is nowhere in the headers or body of the responses the browser received. - Call
GET /bff/api/mewith the cookie from logging in as bob, and write into/root/st/bff/proxy.txtwhether the BFF attaches the Bearer and gets 200 and sub=bob downstream, and whether the cookie is not passed to downstream. - Call downstream
/medirectly with no token, with only the session cookie, and with a bogus Bearer, and write into/root/st/bff/direct.txtwhether all are 401 withWWW-Authenticate: Bearer. - Write into
/root/st/bff/csrf.txtwhetherPOST /bff/api/notesis 201 only with a correctX-CSRF-Tokenand 403 if it is missing or wrong. - After logging out with
curl -X POST, write into/root/st/bff/logout.txtwhether the BFF session is gone and the old access token is 401 downstream. - Test the whole flow with
/root/st/bff/e2e.shand leave six lines in/root/st/bff/e2e.out.
Notes
- Bring up the server with
setsid nohup python3 /root/st/bff/app.py > /root/st/bff/app.log 2>&1 &, and if you changed the code, take it down withpkill -f /root/st/bff/app.pyand bring it up again. - The lab Pod has no python-multipart, so you read the form body yourself with
urllib.parse.parse_qs. - To shorten the flow, the lab omits PKCE and the issuer's login screen. If you use an issuer for real work, check its settings first.
- Common mistake 1: handing the token down once more in the session check response or in a cookie that JS can read — it removes the meaning of clearing the token from the browser.
- Common mistake 2: deleting only the BFF session at logout — even a token that never leaked stays alive on the issuer side until it expires.
Bring up the issuer, downstream API, and BFF
With the single file /root/st/bff/app.py, bring up the issuer (127.0.0.1:8312), the downstream API (127.0.0.1:8311), and the BFF (127.0.0.1:8310). All three return 200 and {"ok":true} for GET /health.
If you run three ThreadingHTTPServers as threads inside one process, bringing them up and down takes a single step. Start the server in the background and poll until all three ports respond.
What the browser receives is one session cookie
Log in in the order GET /login, then the issuer POST /authorize (form user=alice&pw=wonderland), then the BFF /callback, and save the one Set-Cookie line that the callback handed down in /root/st/bff/cookie.txt. The cookie name is __Host-Http-bff, it has HttpOnly, Secure, SameSite=Strict, and Path=/, and it has no Domain.
If you take the 302's Location with curl -w '%{redirect_url}' and pass it to the next request, you can walk through the flow like a browser. The request that sends the account to the issuer is a POST. Section 6.1.3.2 of RFC 10017 makes HttpOnly and Secure MUST.
The token exists only in the BFF session
After logging in, write into /root/st/bff/exposure.txt, as token_in_store=yes, token_in_body=no, and token_in_cookie=no, the result of checking that bff:sess:<세션ID> (JSON) in Redis has access_token (the placeholder is the session ID) and that value is nowhere in the headers or body of the callback response and the GET /bff/session response. GET /bff/session gives only auth, user, and csrf.
Pull the session ID from the cookie jar and the real token with redis-cli GET. Try to find that string in the response files with grep -F. The moment you put the token in the session check response for convenience, the BFF is a BFF in name only.
The BFF calls downstream with a Bearer attached
When you call GET /bff/api/me with the cookie from logging in as bob/builder, the BFF strips the cookie, attaches Authorization: Bearer <토큰> (the placeholder is the token), passes it to downstream GET /me, and gets 200 and {"sub":"bob"}. Downstream GET /inspect tells you through cookie_forwarded whether the cookie was passed over. Write the result into /root/st/bff/proxy.txt as me=200, sub=bob, and cookie_forwarded=False.
The BFF moves the path after /bff/api/ to downstream, and instead of copying the request headers wholesale, make Authorization fresh. Downstream must block a request with no token with a 401, so that a 200 through the BFF is itself evidence that the BFF attached the token.
Calling downstream directly is a 401
Skip the BFF and call downstream GET http://127.0.0.1:8311/me with no token, holding only the BFF session cookie, and with a bogus Bearer value, and write into /root/st/bff/direct.txt, as no_token=401, cookie_only=401, bogus_bearer=401, and www_authenticate=Bearer, whether all are 401 with WWW-Authenticate: Bearer attached. A POST /notes with no token must be a 401 too.
Downstream does not look at the cookie and looks only at the Authorization header. Decide whether the token is alive by asking the issuer's introspection. Section 3 of RFC 6750 defines the header to attach to a 401 response.
The BFF's writes are protected by a CSRF token
Only when you send the csrf value of GET /bff/session as the X-CSRF-Token header does POST /bff/api/notes (form text=...) pass to downstream and become 201, and write into /root/st/bff/csrf.txt, as no_header=403, wrong=403, and right=201, whether a missing or wrong header is 403 and is not recorded downstream.
Because the browser carries the cookie automatically, the cookie alone cannot be told apart from a forged request. Put the CSRF token into the session as well and compare with hmac.compare_digest. The rejection must happen before calling downstream.
Logout ends the session and the token together
After logging in, call POST /logout with curl -X POST (with the cookie and X-CSRF-Token), and write into /root/st/bff/logout.txt, as session_auth=False, store_key_gone=yes, and old_token_direct=401, whether GET /bff/session with the same cookie has auth False, bff:sess:<세션ID> in Redis has disappeared (the placeholder is the session ID), and calling downstream /me directly with the access token you pulled out before logout is 401.
If you delete only the session key, the token on the issuer side stays alive until it expires. At logout the BFF must first call the issuer's revocation endpoint (RFC 7009). If you leave out -X POST, curl sends a GET.
Prove the whole flow at once
With /root/st/bff/e2e.sh, test from login to logout and leave six lines in /root/st/bff/e2e.out: cookie_httponly=yes, token_exposed=no, via_bff=200, direct_no_token=401, csrf_missing=403, and after_logout_token=401. The grader also reruns e2e.sh to see whether the same result comes out.
Chain the earlier steps together in one script. If the script deletes the temporary files it made and produces its result only on standard output, it is safe to run again.