Skip to content

Commit ba1ea67

Browse files
ClaydeCodeclaude
andcommitted
feat(oidc): security hardening from OIDC review — S256-only PKCE, atomic code redemption, reuse family revocation
Applies the three findings from the 2026-07-11 security review (RFC 9700): - PKCE accepts S256 only. Authlib's default also allows 'plain', where the challenge travels as the cleartext verifier — useless against the authorization-request interception PKCE exists for. Discovery no longer advertises plain. - Authorization codes are consumed atomically (single UPDATE ... RETURNING on a redeemed flag), closing the TOCTOU race where two concurrent /token requests could both redeem one code. Any redemption attempt burns the code, including ones that later fail PKCE. Redeemed rows are kept until expiry: reuse of a redeemed code signals interception and revokes every token issued to that (client, user) grant. Side effect: the nonce-replay window now actually spans the code lifetime. - Replay of a rotated-out refresh token revokes the whole token family, so a thief who rotates first no longer keeps a live token while the legit client is silently rejected. Per-client rate-limit buckets (finding 4) deferred — the global in-process guard stays; noted as follow-up when it matters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d335778 commit ba1ea67

4 files changed

Lines changed: 136 additions & 16 deletions

File tree

migrations/shard-core-0003-oidc.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS oidc_codes (
2121
code_challenge TEXT,
2222
code_challenge_method TEXT,
2323
auth_time BIGINT NOT NULL,
24-
expires_at TIMESTAMPTZ NOT NULL
24+
expires_at TIMESTAMPTZ NOT NULL,
25+
redeemed BOOLEAN NOT NULL DEFAULT FALSE
2526
);
2627

2728
CREATE TABLE IF NOT EXISTS oidc_tokens (

shard_core/database/oidc.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,30 @@ async def insert_code(conn: AsyncConnection, code: dict):
4646
await conn.execute(sql, code)
4747

4848

49+
async def redeem_code(
50+
conn: AsyncConnection, code_hash: str, client_id: str
51+
) -> dict | None:
52+
"""Atomically consume the code — the single UPDATE makes concurrent
53+
redemptions of the same code impossible (only one caller gets the row)."""
54+
sql: LiteralString = """UPDATE oidc_codes SET redeemed = TRUE
55+
WHERE code_hash = %s AND client_id = %s AND NOT redeemed AND expires_at > now()
56+
RETURNING *"""
57+
async with conn.cursor(row_factory=dict_row) as cur:
58+
await cur.execute(sql, (code_hash, client_id))
59+
return await cur.fetchone()
60+
61+
4962
async def get_code(
5063
conn: AsyncConnection, code_hash: str, client_id: str
5164
) -> dict | None:
5265
sql: LiteralString = (
53-
"SELECT * FROM oidc_codes WHERE code_hash = %s AND client_id = %s AND expires_at > now()"
66+
"SELECT * FROM oidc_codes WHERE code_hash = %s AND client_id = %s"
5467
)
5568
async with conn.cursor(row_factory=dict_row) as cur:
5669
await cur.execute(sql, (code_hash, client_id))
5770
return await cur.fetchone()
5871

5972

60-
async def delete_code(conn: AsyncConnection, code_hash: str):
61-
sql: LiteralString = "DELETE FROM oidc_codes WHERE code_hash = %s"
62-
await conn.execute(sql, (code_hash,))
63-
64-
6573
async def exists_nonce(conn: AsyncConnection, nonce: str, client_id: str) -> bool:
6674
sql: LiteralString = "SELECT 1 FROM oidc_codes WHERE nonce = %s AND client_id = %s"
6775
async with conn.cursor() as cur:
@@ -94,9 +102,9 @@ async def get_token_by_access_hash(
94102
async def get_token_by_refresh_hash(
95103
conn: AsyncConnection, refresh_token_hash: str
96104
) -> dict | None:
97-
sql: LiteralString = (
98-
"SELECT * FROM oidc_tokens WHERE refresh_token_hash = %s AND NOT revoked"
99-
)
105+
# revoked rows included on purpose — rotated-token replay must be
106+
# distinguishable from an unknown token (reuse detection)
107+
sql: LiteralString = "SELECT * FROM oidc_tokens WHERE refresh_token_hash = %s"
100108
async with conn.cursor(row_factory=dict_row) as cur:
101109
await cur.execute(sql, (refresh_token_hash,))
102110
return await cur.fetchone()
@@ -107,3 +115,10 @@ async def revoke_token(conn: AsyncConnection, access_token_hash: str):
107115
"UPDATE oidc_tokens SET revoked = TRUE WHERE access_token_hash = %s"
108116
)
109117
await conn.execute(sql, (access_token_hash,))
118+
119+
120+
async def revoke_all_for_grant(conn: AsyncConnection, client_id: str, user_sub: int):
121+
sql: LiteralString = (
122+
"UPDATE oidc_tokens SET revoked = TRUE WHERE client_id = %s AND user_sub = %s"
123+
)
124+
await conn.execute(sql, (client_id, user_sub))

shard_core/service/oidc_provider.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,31 @@ def save_authorization_code(self, code, request):
265265
)
266266

267267
def query_authorization_code(self, code, client):
268-
row = _run(_with_conn(db_oidc.get_code, hash_secret(code), client.client_id))
268+
# atomic burn-on-query: any redemption attempt (even one that later
269+
# fails PKCE) consumes the code, and two concurrent requests can't
270+
# both get it (RFC 9700 single-use)
271+
row = _run(_with_conn(db_oidc.redeem_code, hash_secret(code), client.client_id))
272+
if row is None:
273+
stale = _run(
274+
_with_conn(db_oidc.get_code, hash_secret(code), client.client_id)
275+
)
276+
if stale and stale["redeemed"]:
277+
# reuse of a redeemed code signals interception — kill every
278+
# token issued to this (client, user) grant
279+
_run(
280+
_with_conn(
281+
db_oidc.revoke_all_for_grant,
282+
client.client_id,
283+
stale["user_sub"],
284+
)
285+
)
286+
return None
269287
return AuthorizationCode.from_row(code, row)
270288

271289
def delete_authorization_code(self, authorization_code):
272-
_run(_with_conn(db_oidc.delete_code, hash_secret(authorization_code.code)))
290+
# already consumed atomically in query_authorization_code; the row is
291+
# kept (redeemed) for reuse detection until it expires
292+
pass
273293

274294
def authenticate_user(self, authorization_code):
275295
return _run(_user_from_id_async(authorization_code.user_sub))
@@ -282,7 +302,18 @@ def authenticate_refresh_token(self, refresh_token):
282302
row = _run(
283303
_with_conn(db_oidc.get_token_by_refresh_hash, hash_secret(refresh_token))
284304
)
285-
if row and row["issued_at"] + REFRESH_TOKEN_LIFETIME > time.time():
305+
if row is None:
306+
return None
307+
if row["revoked"]:
308+
# replay of a rotated-out refresh token — revoke the whole family
309+
# so a thief who rotated first doesn't keep a live token
310+
_run(
311+
_with_conn(
312+
db_oidc.revoke_all_for_grant, row["client_id"], row["user_sub"]
313+
)
314+
)
315+
return None
316+
if row["issued_at"] + REFRESH_TOKEN_LIFETIME > time.time():
286317
return _TokenRecord(row)
287318
return None
288319

@@ -381,6 +412,12 @@ def _query_client(client_id: str):
381412
return OidcClient.from_row(_run(_with_conn(db_oidc.get_client, client_id)))
382413

383414

415+
class S256CodeChallenge(CodeChallenge):
416+
# 'plain' (Authlib's default second method) sends challenge == verifier in
417+
# cleartext, defeating PKCE's interception protection (RFC 9700 wants S256)
418+
SUPPORTED_CODE_CHALLENGE_METHOD = ["S256"]
419+
420+
384421
def build_authorization_server(server_cls=AuthorizationServer) -> AuthorizationServer:
385422
"""server_cls lets the web layer pass its framework-adapter subclass."""
386423
server = server_cls(scopes_supported=SUPPORTED_SCOPES)
@@ -389,7 +426,7 @@ def build_authorization_server(server_cls=AuthorizationServer) -> AuthorizationS
389426
server.register_token_generator("default", _generate_bearer_token)
390427
server.register_grant(
391428
ShardAuthCodeGrant,
392-
[CodeChallenge(required=True), ShardOpenIDCode(require_nonce=False)],
429+
[S256CodeChallenge(required=True), ShardOpenIDCode(require_nonce=False)],
393430
)
394431
server.register_grant(ShardRefreshTokenGrant)
395432
return server
@@ -460,5 +497,5 @@ def discovery_document() -> dict:
460497
"client_secret_post",
461498
"none",
462499
],
463-
"code_challenge_methods_supported": ["S256", "plain"],
500+
"code_challenge_methods_supported": ["S256"],
464501
}

tests/test_oidc.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ async def test_discovery_document(app_client: AsyncClient):
144144
assert "public" in disco["subject_types_supported"]
145145
assert "RS256" in disco["id_token_signing_alg_values_supported"]
146146
assert "openid" in disco["scopes_supported"]
147-
assert "S256" in disco["code_challenge_methods_supported"]
147+
assert disco["code_challenge_methods_supported"] == ["S256"]
148148
assert {"authorization_code", "refresh_token"} <= set(
149149
disco["grant_types_supported"]
150150
)
@@ -313,6 +313,12 @@ async def test_refresh_rotation(app_client: AsyncClient):
313313
)
314314
assert r.status_code in (400, 401), r.text
315315

316+
# ...and its replay is treated as compromise: the whole family dies
317+
r = await app_client.get(
318+
USERINFO, headers={"Authorization": f"Bearer {new_tok['access_token']}"}
319+
)
320+
assert r.status_code == 401, "family not revoked after refresh-token reuse"
321+
316322

317323
# --- negatives -----------------------------------------------------------------------
318324

@@ -408,6 +414,67 @@ async def test_scope_narrowing(app_client: AsyncClient):
408414
assert {"openid", "profile"} <= granted
409415

410416

417+
async def test_plain_pkce_rejected(app_client: AsyncClient):
418+
"""RFC 9700: only S256 — 'plain' sends the verifier in cleartext."""
419+
await pair_new_terminal(app_client)
420+
oidc_client = await make_client()
421+
verifier = secrets.token_urlsafe(32)
422+
params, r = await authorize(
423+
app_client,
424+
oidc_client,
425+
verifier,
426+
code_challenge=verifier,
427+
code_challenge_method="plain",
428+
)
429+
if 300 <= r.status_code < 400:
430+
query = parse_qs(urlparse(r.headers["location"]).query)
431+
assert "code" not in query, "code issued for plain PKCE"
432+
assert "error" in query
433+
else:
434+
assert r.status_code == 400, r.text
435+
436+
437+
async def test_failed_redemption_burns_code(app_client: AsyncClient):
438+
"""Any redemption attempt consumes the code — a PKCE-failing attempt
439+
must not leave the code redeemable."""
440+
await pair_new_terminal(app_client)
441+
oidc_client = await make_client()
442+
verifier = secrets.token_urlsafe(32)
443+
code = await get_code(app_client, oidc_client, verifier)
444+
445+
r = await exchange_code(app_client, oidc_client, code, "wrong-" + verifier)
446+
assert r.status_code == 400, r.text
447+
448+
r = await exchange_code(app_client, oidc_client, code, verifier)
449+
assert r.status_code == 400, "code survived a failed redemption attempt"
450+
451+
452+
async def test_code_reuse_revokes_issued_tokens(app_client: AsyncClient):
453+
"""Reuse of a redeemed code signals interception — tokens issued to the
454+
grant must be revoked (RFC 9700)."""
455+
await pair_new_terminal(app_client)
456+
oidc_client = await make_client()
457+
verifier = secrets.token_urlsafe(32)
458+
code = await get_code(app_client, oidc_client, verifier)
459+
460+
r = await exchange_code(app_client, oidc_client, code, verifier)
461+
assert r.status_code == 200, r.text
462+
tok = r.json()
463+
464+
r = await app_client.get(
465+
USERINFO, headers={"Authorization": f"Bearer {tok['access_token']}"}
466+
)
467+
assert r.status_code == 200
468+
469+
r = await exchange_code(app_client, oidc_client, code, verifier)
470+
assert r.status_code == 400, r.text
471+
472+
r = await app_client.get(
473+
USERINFO, headers={"Authorization": f"Bearer {tok['access_token']}"}
474+
)
475+
assert r.status_code == 401, "tokens not revoked after code reuse"
476+
477+
411478
# --- storage hardening -----------------------------------------------------------------
412479

413480

0 commit comments

Comments
 (0)