ЁЯПл The SchoolтА║ЁЯЫбя╕П SecurityтА║ЁЯОл рдзрдбрд╛ 05 тАФ OAuth 2 рдЖрдгрд┐ OIDC: рд╢рд╛рд│реЗрдЪреНрдпрд╛ account рдиреЗ log in рдХрд░рд╛
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯОл рдзрдбрд╛ 05 тАФ OAuth 2 рдЖрдгрд┐ OIDC: рд╢рд╛рд│реЗрдЪреНрдпрд╛ account рдиреЗ log in рдХрд░рд╛

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 16 рдкреИрдХреА рдзрдбрд╛ 05 ┬╖ рдорд╛рдЧреЗ: lesson-04-authentication ┬╖ рдкреБрдвреЗ: lesson-06-authorization


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ04, рдЖрдгрд┐ delegated login. рд╢рд╛рд│реЗрдЪреЗ app passwords рдареЗрд╡рдд рдирд╛рд╣реА. рддреЗ user рд▓рд╛ рд╢рд╛рд│реЗрдЪреНрдпрд╛ identity provider рдХрдбреЗ рдкрд╛рдард╡рддреЗ, рдЖрдгрд┐ рдкрд░рдд рдПрдХ ID token (OpenID Connect) рдЖрдгрд┐ рдПрдХ access token (OAuth 2) рдорд┐рд│рд╡рддреЗ. рддреБрдореНрд╣реА token рдпреЛрдЧреНрдп рдкрджреНрдзрддреАрдиреЗ рддрдкрд╛рд╕рддрд╛ тАФ algorithm, signature, issuer, audience, expiry тАФ рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХ рдЦреЛрдЯреЗрдкрдгрд╛ fail рд╣реЛрддрд╛рдирд╛ рдкрд╛рд╣рддрд╛. рдордЧ PKCE, рдЬреНрдпрд╛рдореБрд│реЗ рдЪреЛрд░рд▓реЗрд▓рд╛ authorization code рдирд┐рд░реБрдкрдпреЛрдЧреА рд╣реЛрддреЛ. sec/demo.py рдордзрд▓реЗ oauth() рд╣реЗ рджрд╛рдЦрд╡рддреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

Library рд▓рд╛ рджреАрдкрд┐рдХрд╛ рдХреЛрдг рдЖрд╣реЗ рддреЗ рдЬрд╛рдгреВрди рдШреНрдпрд╛рдпрдЪреЗ рдЖрд╣реЗ. рддреА рддрд┐рдЪрд╛ рдЧреБрдкреНрдд рд╢рдмреНрдж рд╡рд┐рдЪрд╛рд░рдд рдирд╛рд╣реА тАФ рддреЛ рдлрдХреНрдд рд╢рд╛рд│реЗрдЪреА рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреА рдРрдХреВ рд╢рдХрддреЗ.

рдореНрд╣рдгреВрди library рджреАрдкрд┐рдХрд╛рд▓рд╛ рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреАрдХрдбреЗ рдкрд╛рдард╡рддреЗ. рддреА рдЬрд╛рдгреНрдпрд╛рдЖрдзреА library рдПрдХрд╛ рдХрд╛рдЧрджрд╛рд╡рд░ рдПрдХ рдЧреБрдкреНрдд рд╢рдмреНрдж рд▓рд┐рд╣рд┐рддреЗ, рддреЛ рдЦрд┐рд╢рд╛рдд рдареЗрд╡рддреЗ, рдЖрдгрд┐ рджреАрдкрд┐рдХрд╛рд▓рд╛ рдЦрд┐рдбрдХреАрд╡рд░ рджрд╛рдЦрд╡рдгреНрдпрд╛рд╕рд╛рдареА рдлрдХреНрдд рддреНрдпрд╛ рд╢рдмреНрджрд╛рдЪрд╛ рдард╕рд╛ рджреЗрддреЗ (PKCE challenge).

рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреА рджреАрдкрд┐рдХрд╛рд▓рд╛ рддрдкрд╛рд╕рддреЗ рдЖрдгрд┐ рддрд┐рд▓рд╛ рдПрдХ рдкрд╛рд╡рддреА рджреЗрддреЗ (authorization code). рддреА рддреА library рдХрдбреЗ рдкрд░рдд рдЖрдгрддреЗ. Library рддреА рдкрд╛рд╡рддреА рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреАрдХрдбреЗ рдШреЗрдКрди рдЬрд╛рддреЗ рдЖрдгрд┐ рдЦрд┐рд╢рд╛рддрд▓рд╛ рдЧреБрдкреНрдд рд╢рдмреНрдж рджрд╛рдЦрд╡рддреЗ (verifier). рдЦрд┐рдбрдХреА рддрдкрд╛рд╕рддреЗ: "рдпрд╛ рд╢рдмреНрджрд╛рдкрд╛рд╕реВрди рддреЛ рдард╕рд╛ рдмрдирддреЛ рдХрд╛?" рд╣реЛ тЖТ рдЦрд┐рдбрдХреА рджреАрдкрд┐рдХрд╛рдЪреЗ рдУрд│рдЦрдкрддреНрд░ (tokens) рджреЗрддреЗ.

рд╡рд╛рдЯреЗрдд рдПрдЦрд╛рджреНрдпрд╛ рдЕрдиреЛрд│рдЦреА рдорд╛рдгрд╕рд╛рдиреЗ рдкрд╛рд╡рддреА рдЪреЛрд░рд▓реА рддрд░реА рддреА рдирд┐рд░реБрдкрдпреЛрдЧреА рдЕрд╕рддреЗ тАФ library рдЪреНрдпрд╛ рдЦрд┐рд╢рд╛рддрд▓рд╛ рд╢рдмреНрдж рддреНрдпрд╛рдЪреНрдпрд╛рдХрдбреЗ рдирд╕рддреЛ.

рдЖрдгрд┐ library рдУрд│рдЦрдкрддреНрд░рд╛рд╡рд░ рд╡рд┐рд╢реНрд╡рд╛рд╕ рдареЗрд╡рдгреНрдпрд╛рдЖрдзреА рддреЗ рддрдкрд╛рд╕рддреЗ: рд╢рд╛рд│реЗрдЪрд╛ рд╢рд┐рдХреНрдХрд╛ (signature), рд╢рд╛рд│реЗрдЪреНрдпрд╛ рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреАрдХрдбреВрди (issuer), canteen рд╕рд╛рдареА рдирд╛рд╣реА рддрд░ library рд╕рд╛рдареА (audience), рдЖрдгрд┐ expired рдирд╛рд╣реА.

ЁЯЧ║я╕П рдЖрдХреГрддреА

sequenceDiagram
    participant U as ЁЯзТ Dipika's browser
    participant A as ЁЯУЪ school app
    participant I as ЁЯОл identity provider
    A->>A: verifier = random ┬╖ challenge = BASE64URL(SHA256(verifier))
    A->>U: redirect to /authorize with response_type=code, code_challenge (S256), state, nonce
    U->>I: log in (password + MFA or passkey)
    I->>U: redirect back with code and state
    U->>A: code + state
    A->>I: POST /token: code + code_verifier
    I->>I: SHA256(verifier) == challenge?
    I->>A: ID token + access token
    A->>A: check alg ┬╖ signature ┬╖ iss ┬╖ aud ┬╖ exp ┬╖ nonce

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l05

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рддреБрдореНрд╣реА рд╕реЛрдбрд▓реЗрд▓реА рдкреНрд░рддреНрдпреЗрдХ token рддрдкрд╛рд╕рдгреА рдореНрд╣рдгрдЬреЗ рдПрдХ рджрд╛рд░. Signature рд╕реЛрдбрд▓реЗ рддрд░ рдХреЛрдгреАрд╣реА "role": "admin" рд▓рд┐рд╣реВ рд╢рдХрддреЛ. aud рд╕реЛрдбрд▓реЗ рддрд░ canteen app рд▓рд╛ рджрд┐рд▓реЗрд▓рд╛ token library рдордзреНрдпреЗ рдЪрд╛рд▓рддреЛ. exp рд╕реЛрдбрд▓реЗ рддрд░ рдЧреЗрд▓реНрдпрд╛ рд╡рд░реНрд╖реА рдЪреЛрд░рд▓реЗрд▓рд╛ token рдЕрдЬреВрдирд╣реА рдЪрд╛рд▓рддреЛ. alg: none рд╕реНрд╡реАрдХрд╛рд░рд▓реЗ рддрд░ signature рддрдкрд╛рд╕рдгреА рд╣рд▓реНрд▓реЗрдЦреЛрд░рдЪ рдмрдВрдж рдХрд░рддреЛ. рдЖрдгрд┐ PKCE рд╢рд┐рд╡рд╛рдп, рддреНрдпрд╛рдЪ phone рд╡рд░рдЪреНрдпрд╛ рджреБрд╖реНрдЯ app рдиреЗ рдкрдХрдбрд▓реЗрд▓рд╛, рдХрд┐рдВрд╡рд╛ log рдордзреНрдпреЗ leak рдЭрд╛рд▓реЗрд▓рд╛ code, user рдЪреНрдпрд╛ tokens рдордзреНрдпреЗ рдмрджрд▓рддреЛ.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

sec/identity.py рдордзреНрдпреЗ: make_jwt(claims, secret, alg) header.payload.signature token рдмрдирд╡рддреЗ (HMAC-SHA256 тАФ shared secret рдореБрд│реЗ lab рдЫреЛрдЯрд╛ рд░рд╛рд╣рддреЛ; рдЦрд░реЗ IdPs private key рдиреЗ sign рдХрд░рддрд╛рдд рдЖрдгрд┐ public keys рдкреНрд░рдХрд╛рд╢рд┐рдд рдХрд░рддрд╛рдд). verify_jwt(token, secret, issuer, audience, now) рдпрд╛ рдХреНрд░рдорд╛рдиреЗ рддрдкрд╛рд╕рддреЗ: algorithm HS256 рдЪ рдЕрд╕рд▓рд╛ рдкрд╛рд╣рд┐рдЬреЗ, signature, iss, aud, рдордЧ exp. pkce_pair(verifier) S256 challenge рдкрд░рдд рджреЗрддреЗ; pkce_ok(challenge, verifier) рддреЛ рдкреБрдиреНрд╣рд╛ рдЧрдгрди рдХрд░рддреЗ. Lab рдордзреНрдпреЗ nonce рдЪреЗ model рдирд╛рд╣реА тАФ рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рдкрд╛рд╣рд╛.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 sec/demo.py oauth
python3 - <<'EOF'
import sys, base64, json; sys.path.insert(0, "sec"); from identity import make_jwt, verify_jwt, pkce_pair, pkce_ok
NOW, KEY, ISS = 1_790_000_000, "school-idp-secret", "https://login.school.example"
tok = make_jwt(dict(iss=ISS, aud="school-app", sub="dipika", role="parent", exp=NOW + 300), KEY)
h, p, s = tok.split(".")
claims = json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
print("anyone can READ the claims:", claims)
claims["role"] = "admin"
p2 = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode()
print("role changed to admin тЖТ", verify_jwt(f"{h}.{p2}.{s}", KEY, ISS, "school-app", NOW)[1])
other = make_jwt(dict(iss="https://login.other.example", aud="school-app", exp=NOW + 300), KEY)
print("another issuer        тЖТ", verify_jwt(other, KEY, ISS, "school-app", NOW)[1])
print("five minutes later    тЖТ", verify_jwt(tok, KEY, ISS, "school-app", NOW + 300)[1])
v, c = pkce_pair("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
print("challenge (S256)      тЖТ", c)
print("same as RFC 7636, Appendix B тЖТ", c == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
print("right verifier тЖТ", pkce_ok(c, v), "┬╖ sending the challenge back тЖТ", pkce_ok(c, c))
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

oauth рд╣реЗ print рдХрд░рддреЗ:

тФАтФА the ID token the school login gives the app (OIDC) тАФ checked before trusting any claim:
   good       тЖТ ok
   forged     тЖТ bad signature
   alg none   тЖТ algorithm 'none' refused
   other app  тЖТ wrong audience
   expired    тЖТ expired
тФАтФА PKCE: the app sends challenge E9Melhoa2OwvFrEMтАж ┬╖ later proves it with the verifier тЖТ True ┬╖ a thief with only the code тЖТ False
   authorization code + PKCE for browsers and phones ┬╖ never the implicit flow ┬╖ tokens short-lived

рддреБрдордЪрд╛ snippet рд╣реЗ print рдХрд░рддреЛ:

anyone can READ the claims: {'iss': 'https://login.school.example', 'aud': 'school-app', 'sub': 'dipika', 'role': 'parent', 'exp': 1790000300}
role changed to admin тЖТ bad signature
another issuer        тЖТ wrong issuer
five minutes later    тЖТ expired
challenge (S256)      тЖТ E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
same as RFC 7636, Appendix B тЖТ True
right verifier тЖТ True ┬╖ sending the challenge back тЖТ False

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

JWT рдордзрд▓реЗ claims рдХреЛрдгреАрд╣реА рд╡рд╛рдЪреВ рд╢рдХрддреЛ тАФ рдлрдХреНрдд signature рддреНрдпрд╛рдВрдЪреЗ рд░рдХреНрд╖рдг рдХрд░рддреЗ, рдЖрдгрд┐ рдПрдХ рдмрджрд▓рд▓реЗрд▓рд╛ claim рддреЗ рдореЛрдбрддреЛ. рдкреНрд░рддреНрдпреЗрдХ рддрдкрд╛рд╕рдгреА рд╡реЗрдЧрд│рд╛ рдЦреЛрдЯреЗрдкрдгрд╛ рдирд╛рдХрд╛рд░рддреЗ: alg none, рджреБрд╕рд▒реНрдпрд╛ app рдЪрд╛ token, рджреБрд╕рд▒реНрдпрд╛ issuer рдЪрд╛ token, рдЬреБрдирд╛ token. Lab рдЪрд╛ PKCE challenge RFC 7636 рдордзрд▓реНрдпрд╛ рдЙрджрд╛рд╣рд░рдгрд╛рд╢реА рдЕрдЧрджреА рдЬреБрд│рддреЛ. рдЖрдгрд┐ challenge рдЙрдШрдбрдкрдгреЗ рдкреНрд░рд╡рд╛рд╕ рдХрд░рддреЛ; рдлрдХреНрдд verifier рдЪ code рд╡рд╛рдкрд░реВ рд╢рдХрддреЛ.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ authorization code flow рдЖрдгрд┐ PKCE рд╕рд╣ OIDC client (Python, Authlib + Flask). рддреЛ IdP рдЪреЗ discovery document рд╡рд╛рдЪрддреЛ, state рдЖрдгрд┐ nonce рдкрд╛рдард╡рддреЛ, рдЖрдгрд┐ ID token рддрдкрд╛рд╕рддреЛ:

from authlib.integrations.flask_client import OAuth
oauth = OAuth(app)
oauth.register(
    name="school",
    server_metadata_url="https://login.school.example/.well-known/openid-configuration",
    client_id=os.environ["OIDC_CLIENT_ID"], client_secret=os.environ["OIDC_CLIENT_SECRET"],
    client_kwargs={"scope": "openid email profile", "code_challenge_method": "S256"},
)
@app.route("/login")
def login():
    return oauth.school.authorize_redirect(url_for("callback", _external=True))
@app.route("/callback")
def callback():
    token = oauth.school.authorize_access_token()     # checks state, swaps code + verifier, validates the ID token and nonce
    session.clear(); session["user"] = token["userinfo"]["sub"]
    return redirect("/")

Token рд╕реНрд╡рддрдГ verify рдХрд░рдгреЗ (access tokens рдШреЗрдгрд╛рд░реА API), PyJWT рдЖрдгрд┐ IdP рдЪреНрдпрд╛ JWKS рд╕рд╣:

import jwt
jwks = jwt.PyJWKClient("https://login.school.example/.well-known/jwks.json")
key = jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(token, key, algorithms=["RS256"],               # pinned тАФ never from the header
                    audience="school-api", issuer="https://login.school.example",
                    leeway=30, options={"require": ["exp", "iss", "aud", "sub"]})

Browser app (single-page app) рдордзреНрдпреЗ oidc-client-ts рд╕рд╛рд░рдЦреА certified library рд╡рд╛рдкрд░рд╛, response_type: "code" рд╕рд╣ тАФ PKCE default рдиреЗ рдЪрд╛рд▓реВ рдЕрд╕рддреЗ тАФ рдЖрдгрд┐ tokens рдЕрд▓реНрдкрд╛рдпреБрд╖реА рдареЗрд╡рд╛.

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: managed identity provider (Amazon Cognito, Microsoft Entra ID, Okta, Auth0, Keycloak) рдЖрдгрд┐ certified client library рд╡рд╛рдкрд░рд╛. рдЕрдЪреВрдХ redirect URIs register рдХрд░рд╛. Access tokens рд▓рд╣рд╛рди (minutes) рдареЗрд╡рд╛, refresh-token rotation рд╡рд╛рдкрд░рд╛, рдЖрдгрд┐ рдпрд╛ рдзрдбреНрдпрд╛рддрд▓реА рдкреНрд░рддреНрдпреЗрдХ рддрдкрд╛рд╕рдгреА library рд▓рд╛ рдХрд░реВ рджреНрдпрд╛ тАФ рдордЧ рджреБрд╕рд▒реНрдпрд╛ audience рд╕рд╛рдареАрдЪрд╛ token рдирд╛рдХрд╛рд░рд▓рд╛ рдЬрд╛рддреЛ рдХрд╛ рддреЗ test рдХрд░рд╛.

тПня╕П рдкреБрдвреЗ

Token рд╕рд╛рдВрдЧрддреЛ рджреАрдкрд┐рдХрд╛ рдХреЛрдг рдЖрд╣реЗ. рддреА рдХреЛрдгрддреЗ records рдЙрдШрдбреВ рд╢рдХрддреЗ рддреЗ рддреЛ рд╕рд╛рдВрдЧрдд рдирд╛рд╣реА. рдкреБрдвреЗ: рдмрд╣реБрддреЗрдХ APIs рд╡рд┐рд╕рд░рддрд╛рдд рддреА authorization рддрдкрд╛рд╕рдгреА.

git checkout lesson-06-authorization

ЁЯОл Lesson 05 тАФ OAuth 2 and OIDC: log in with the school account

ЁЯУН You are here: Lesson 05 of 16 ┬╖ Previous: lesson-04-authentication ┬╖ Next: lesson-06-authorization


ЁЯУж What's in this branch

Lessons 01тАУ04, plus delegated login. The school app does not keep passwords. It sends the user to the school's identity provider, and gets back an ID token (OpenID Connect) and an access token (OAuth 2). You check a token the right way тАФ algorithm, signature, issuer, audience, expiry тАФ and see each forgery fail. Then PKCE, which makes a stolen authorization code useless. oauth() in sec/demo.py shows it.

ЁЯзТ Explain like I'm 5

The library wants to know who Dipika is. It does not ask for her secret word тАФ only the school's ID desk may hear that.

So the library sends Dipika to the ID desk. Before she goes, the library writes a secret word on a piece of paper, keeps it in its pocket, and gives Dipika only a fingerprint of that word to show the desk (the PKCE challenge).

The ID desk checks Dipika and gives her a claim ticket (the authorization code). She brings it back to the library. The library takes the ticket to the ID desk and shows the secret word from its pocket (the verifier). The desk checks: "does this word make that fingerprint?" Yes тЖТ the desk hands over Dipika's ID card (the tokens).

If a stranger steals the claim ticket on the way, it is useless тАФ he does not have the word in the library's pocket.

And the library checks the ID card before it trusts it: stamped by the school (signature), from the school's ID desk (issuer), for the library and not the canteen (audience), and not expired.

ЁЯЧ║я╕П Diagram

sequenceDiagram
    participant U as ЁЯзТ Dipika's browser
    participant A as ЁЯУЪ school app
    participant I as ЁЯОл identity provider
    A->>A: verifier = random ┬╖ challenge = BASE64URL(SHA256(verifier))
    A->>U: redirect to /authorize with response_type=code, code_challenge (S256), state, nonce
    U->>I: log in (password + MFA or passkey)
    I->>U: redirect back with code and state
    U->>A: code + state
    A->>I: POST /token: code + code_verifier
    I->>I: SHA256(verifier) == challenge?
    I->>A: ID token + access token
    A->>A: check alg ┬╖ signature ┬╖ iss ┬╖ aud ┬╖ exp ┬╖ nonce

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l05

тЭУ What

ЁЯдФ Why

Because every token check you skip is a door. Skip the signature and anyone can write "role": "admin". Skip aud and a token issued to the canteen app works at the library. Skip exp and a token stolen last year still works. Accept alg: none and the signature check is switched off by the attacker. And without PKCE, a code caught by a malicious app on the same phone, or leaked in a log, turns into the user's tokens.

ЁЯФз How (in this repo)

In sec/identity.py: make_jwt(claims, secret, alg) builds a header.payload.signature token (HMAC-SHA256 тАФ a shared secret keeps the lab small; real IdPs sign with a private key and publish the public keys). verify_jwt(token, secret, issuer, audience, now) checks, in order: the algorithm must be HS256, the signature, iss, aud, then exp. pkce_pair(verifier) returns the S256 challenge; pkce_ok(challenge, verifier) recomputes it. nonce is not modelled in the lab тАФ see In production.

ЁЯзк Try it

python3 sec/demo.py oauth
python3 - <<'EOF'
import sys, base64, json; sys.path.insert(0, "sec"); from identity import make_jwt, verify_jwt, pkce_pair, pkce_ok
NOW, KEY, ISS = 1_790_000_000, "school-idp-secret", "https://login.school.example"
tok = make_jwt(dict(iss=ISS, aud="school-app", sub="dipika", role="parent", exp=NOW + 300), KEY)
h, p, s = tok.split(".")
claims = json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
print("anyone can READ the claims:", claims)
claims["role"] = "admin"
p2 = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode()
print("role changed to admin тЖТ", verify_jwt(f"{h}.{p2}.{s}", KEY, ISS, "school-app", NOW)[1])
other = make_jwt(dict(iss="https://login.other.example", aud="school-app", exp=NOW + 300), KEY)
print("another issuer        тЖТ", verify_jwt(other, KEY, ISS, "school-app", NOW)[1])
print("five minutes later    тЖТ", verify_jwt(tok, KEY, ISS, "school-app", NOW + 300)[1])
v, c = pkce_pair("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
print("challenge (S256)      тЖТ", c)
print("same as RFC 7636, Appendix B тЖТ", c == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
print("right verifier тЖТ", pkce_ok(c, v), "┬╖ sending the challenge back тЖТ", pkce_ok(c, c))
EOF

тЬЕ Verify тАФ what you should see

oauth prints:

тФАтФА the ID token the school login gives the app (OIDC) тАФ checked before trusting any claim:
   good       тЖТ ok
   forged     тЖТ bad signature
   alg none   тЖТ algorithm 'none' refused
   other app  тЖТ wrong audience
   expired    тЖТ expired
тФАтФА PKCE: the app sends challenge E9Melhoa2OwvFrEMтАж ┬╖ later proves it with the verifier тЖТ True ┬╖ a thief with only the code тЖТ False
   authorization code + PKCE for browsers and phones ┬╖ never the implicit flow ┬╖ tokens short-lived

Your snippet prints:

anyone can READ the claims: {'iss': 'https://login.school.example', 'aud': 'school-app', 'sub': 'dipika', 'role': 'parent', 'exp': 1790000300}
role changed to admin тЖТ bad signature
another issuer        тЖТ wrong issuer
five minutes later    тЖТ expired
challenge (S256)      тЖТ E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
same as RFC 7636, Appendix B тЖТ True
right verifier тЖТ True ┬╖ sending the challenge back тЖТ False

ЁЯПБ What you just proved

The claims inside a JWT are readable by anyone тАФ only the signature protects them, and one changed claim breaks it. Each check refuses a different forgery: alg none, another app's token, another issuer's token, an old token. The lab's PKCE challenge matches the example in RFC 7636 exactly. And the challenge travels in the open; only the verifier redeems the code.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ an OIDC client with the authorization code flow and PKCE (Python, Authlib + Flask). It reads the IdP's discovery document, sends state and nonce, and checks the ID token:

from authlib.integrations.flask_client import OAuth
oauth = OAuth(app)
oauth.register(
    name="school",
    server_metadata_url="https://login.school.example/.well-known/openid-configuration",
    client_id=os.environ["OIDC_CLIENT_ID"], client_secret=os.environ["OIDC_CLIENT_SECRET"],
    client_kwargs={"scope": "openid email profile", "code_challenge_method": "S256"},
)
@app.route("/login")
def login():
    return oauth.school.authorize_redirect(url_for("callback", _external=True))
@app.route("/callback")
def callback():
    token = oauth.school.authorize_access_token()     # checks state, swaps code + verifier, validates the ID token and nonce
    session.clear(); session["user"] = token["userinfo"]["sub"]
    return redirect("/")

Verifying a token yourself (an API that receives access tokens), with PyJWT and the IdP's JWKS:

import jwt
jwks = jwt.PyJWKClient("https://login.school.example/.well-known/jwks.json")
key = jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(token, key, algorithms=["RS256"],               # pinned тАФ never from the header
                    audience="school-api", issuer="https://login.school.example",
                    leeway=30, options={"require": ["exp", "iss", "aud", "sub"]})

In a browser app (a single-page app), use a certified library such as oidc-client-ts, with response_type: "code" тАФ PKCE is on by default тАФ and keep tokens short-lived.

ЁЯПн Why this matters in production: use a managed identity provider (Amazon Cognito, Microsoft Entra ID, Okta, Auth0, Keycloak) and a certified client library. Register exact redirect URIs. Keep access tokens short (minutes), use refresh-token rotation, and let the library do every check in this lesson тАФ then test that a token for another audience is refused.

тПня╕П Next

The token says who Dipika is. It does not say which records she may open. Next: the authorization check most APIs forget.

git checkout lesson-06-authorization
тЖР PreviousauthenticationNext тЖТauthorization

This page is the lesson's README from the lesson-05-oauth-oidc branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.