Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f5568f0
tests: drop the unused large_app fixture
ClaydeCode Aug 25, 2026
b122372
tests, meta: build the mock app store zips at test time
ClaydeCode Aug 25, 2026
02bd02d
Merge pull request #209 from FreeshardBase/chore/clayde/mock-app-stor…
max-tet Aug 25, 2026
f19a068
feat(oidc): phase-2a embedded OIDC provider — productionized from the…
ClaydeCode Jul 10, 2026
f849c14
fix(oidc): adapt provider to numeric user ids from phase-1 review
ClaydeCode Jul 11, 2026
c3374d5
fix(oidc): ShardUser builds from User model (db module returns object…
ClaydeCode Jul 11, 2026
510a65f
feat(oidc): security hardening from OIDC review — S256-only PKCE, ato…
ClaydeCode Jul 11, 2026
3b377b1
fix(oidc): build the request URL from the shard's domain instead of t…
ClaydeCode Jul 27, 2026
a9ed08c
feat(oidc): gate the provider behind an oidc.enabled config flag
ClaydeCode Jul 27, 2026
57ce06b
feat(oidc): bind grants to the authorizing terminal and emit sid
ClaydeCode Jul 29, 2026
0858c2f
fix(db): renumber the oidc migration to 0004
ClaydeCode Aug 24, 2026
7c60441
service: return the stored OIDC client row from register_client
ClaydeCode Aug 25, 2026
5b9168a
web: say what the terminal_id assignment does, not what it implies
ClaydeCode Aug 26, 2026
dd79d02
service, database: guard the sync-to-async bridge and move with_conn
ClaydeCode Aug 27, 2026
7e4dcad
web: rebuild the public URL from the forwarded headers
ClaydeCode Aug 28, 2026
af09b65
service, database: model-typed OIDC rows, an FK to apps, no logout uri
ClaydeCode Aug 28, 2026
cdde281
service: align the bridge timeout with the connection pool timeout
ClaydeCode Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ celerybeat-schedule

# dotenv
.env
!tests/mock_app_store/*/.env

# virtualenv
.venv/
Expand Down
3 changes: 3 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ enabled = false
enabled = false
send_interval_seconds = 300

[oidc]
enabled = false

[management]
api_url = "https://ptlfunctionapp.azurewebsites.net/api/management"

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ services:
- FREESHARD_TRAEFIK__DISABLE_SSL=${DISABLE_SSL:-false}
- FREESHARD_PATH_ROOT_HOST=${FREESHARD_DIR:?}
- FREESHARD_APPS__LIFECYCLE__PAUSE_ENABLED=${PAUSE_ENABLED:-false}
- FREESHARD_OIDC__ENABLED=${OIDC_ENABLED:-false}
depends_on:
postgres:
condition: service_healthy
Expand Down
50 changes: 50 additions & 0 deletions migrations/shard-core-0004-oidc.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- shard-core-0004-oidc
-- depends: shard-core-0003-app-status-message

CREATE TABLE IF NOT EXISTS oidc_clients (
client_id TEXT PRIMARY KEY,
client_secret TEXT,
app_name TEXT UNIQUE NOT NULL
REFERENCES installed_apps (name) ON DELETE CASCADE,
redirect_uris JSONB NOT NULL,
scope TEXT NOT NULL DEFAULT 'openid profile email',
token_endpoint_auth_method TEXT NOT NULL DEFAULT 'client_secret_basic',
Comment thread
max-tet marked this conversation as resolved.
created TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- terminal_id is ON DELETE SET NULL, deliberately not CASCADE: un-pairing must
-- leave the rows in place. A deleted token row is indistinguishable from an
-- unknown one, which loses both the ability to deny a presented token and the
-- refresh-token reuse detection that get_token_by_refresh_hash relies on.
CREATE TABLE IF NOT EXISTS oidc_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oidc_clients (client_id) ON DELETE CASCADE,
redirect_uri TEXT,
scope TEXT,
user_sub BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
terminal_id TEXT REFERENCES terminals (id) ON DELETE SET NULL,
sid TEXT NOT NULL,
nonce TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
auth_time BIGINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
redeemed BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE INDEX IF NOT EXISTS oidc_codes_terminal_id_idx ON oidc_codes (terminal_id);

CREATE TABLE IF NOT EXISTS oidc_tokens (
access_token_hash TEXT PRIMARY KEY,
refresh_token_hash TEXT UNIQUE,
client_id TEXT NOT NULL REFERENCES oidc_clients (client_id) ON DELETE CASCADE,
user_sub BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
terminal_id TEXT REFERENCES terminals (id) ON DELETE SET NULL,
sid TEXT NOT NULL,
scope TEXT,
issued_at BIGINT NOT NULL,
expires_in BIGINT NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE INDEX IF NOT EXISTS oidc_tokens_terminal_id_idx ON oidc_tokens (terminal_id);
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies = [
"python-multipart",
"aiofiles",
"httpx",
"authlib>=1.7.2,<1.8",
]

[project.optional-dependencies]
Expand Down
48 changes: 48 additions & 0 deletions shard_core/data_model/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from datetime import datetime

from pydantic import BaseModel


class OidcClient(BaseModel):
"""A row of oidc_clients — the OIDC client registered for an installed app."""

client_id: str
client_secret: str | None = None
app_name: str
redirect_uris: list[str]
scope: str
token_endpoint_auth_method: str
created: datetime | None = None


class OidcCode(BaseModel):
"""A row of oidc_codes. The code itself is only ever stored as a digest."""

code_hash: str
client_id: str
redirect_uri: str | None = None
scope: str | None = None
user_sub: int
terminal_id: str | None = None
sid: str
nonce: str | None = None
code_challenge: str | None = None
code_challenge_method: str | None = None
auth_time: int
expires_at: datetime
redeemed: bool = False


class OidcToken(BaseModel):
"""A row of oidc_tokens. Both tokens are only ever stored as digests."""

access_token_hash: str
refresh_token_hash: str | None = None
client_id: str
user_sub: int
terminal_id: str | None = None
sid: str
scope: str | None = None
issued_at: int
expires_in: int
revoked: bool = False
6 changes: 6 additions & 0 deletions shard_core/database/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,9 @@ def get_connection_pool() -> AsyncConnectionPool:
async def db_conn() -> AsyncGenerator[AsyncConnection, None]:
async with get_connection_pool().connection() as conn:
yield conn


async def with_conn(fn, *args):
"""Run a single conn-first database function on its own pooled connection."""
async with db_conn() as conn:
return await fn(conn, *args)
146 changes: 146 additions & 0 deletions shard_core/database/oidc.py
Comment thread
max-tet marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
from typing import LiteralString

from psycopg import AsyncConnection
from psycopg.rows import class_row
from psycopg.types.json import Jsonb

from shard_core.data_model.oidc import OidcClient, OidcCode, OidcToken

# --- clients ---------------------------------------------------------------


async def upsert_client(conn: AsyncConnection, client: dict) -> OidcClient:
sql: LiteralString = """INSERT INTO oidc_clients
(client_id, client_secret, app_name, redirect_uris,
scope, token_endpoint_auth_method)
VALUES (%(client_id)s, %(client_secret)s, %(app_name)s, %(redirect_uris)s,
%(scope)s, %(token_endpoint_auth_method)s)
ON CONFLICT (app_name) DO UPDATE SET
client_id = EXCLUDED.client_id,
client_secret = EXCLUDED.client_secret,
redirect_uris = EXCLUDED.redirect_uris,
scope = EXCLUDED.scope,
token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method
RETURNING *"""
async with conn.cursor(row_factory=class_row(OidcClient)) as cur:
await cur.execute(
sql, {**client, "redirect_uris": Jsonb(client["redirect_uris"])}
)
return await cur.fetchone()


async def get_client(conn: AsyncConnection, client_id: str) -> OidcClient | None:
sql: LiteralString = "SELECT * FROM oidc_clients WHERE client_id = %s"
async with conn.cursor(row_factory=class_row(OidcClient)) as cur:
await cur.execute(sql, (client_id,))
return await cur.fetchone()


# --- authorization codes ------------------------------------------------------


async def insert_code(conn: AsyncConnection, code: dict):
sql: LiteralString = """INSERT INTO oidc_codes
(code_hash, client_id, redirect_uri, scope, user_sub, terminal_id, sid, nonce,
code_challenge, code_challenge_method, auth_time, expires_at)
VALUES (%(code_hash)s, %(client_id)s, %(redirect_uri)s, %(scope)s, %(user_sub)s,
%(terminal_id)s, %(sid)s,
%(nonce)s, %(code_challenge)s, %(code_challenge_method)s,
%(auth_time)s, %(expires_at)s)"""
await conn.execute(sql, code)


async def redeem_code(
conn: AsyncConnection, code_hash: str, client_id: str
) -> OidcCode | None:
"""Atomically consume the code — the single UPDATE makes concurrent
redemptions of the same code impossible (only one caller gets the row)."""
sql: LiteralString = """UPDATE oidc_codes SET redeemed = TRUE
WHERE code_hash = %s AND client_id = %s AND NOT redeemed AND expires_at > now()
RETURNING *"""
async with conn.cursor(row_factory=class_row(OidcCode)) as cur:
await cur.execute(sql, (code_hash, client_id))
return await cur.fetchone()
Comment thread
max-tet marked this conversation as resolved.


async def get_code(
conn: AsyncConnection, code_hash: str, client_id: str
) -> OidcCode | None:
sql: LiteralString = (
"SELECT * FROM oidc_codes WHERE code_hash = %s AND client_id = %s"
)
async with conn.cursor(row_factory=class_row(OidcCode)) as cur:
await cur.execute(sql, (code_hash, client_id))
return await cur.fetchone()


async def exists_nonce(conn: AsyncConnection, nonce: str, client_id: str) -> bool:
sql: LiteralString = "SELECT 1 FROM oidc_codes WHERE nonce = %s AND client_id = %s"
async with conn.cursor() as cur:
await cur.execute(sql, (nonce, client_id))
return await cur.fetchone() is not None


# --- tokens --------------------------------------------------------------------


async def insert_token(conn: AsyncConnection, token: dict):
sql: LiteralString = """INSERT INTO oidc_tokens
(access_token_hash, refresh_token_hash, client_id, user_sub, terminal_id, sid,
scope, issued_at, expires_in)
VALUES (%(access_token_hash)s, %(refresh_token_hash)s, %(client_id)s, %(user_sub)s,
%(terminal_id)s, %(sid)s,
%(scope)s, %(issued_at)s, %(expires_in)s)"""
await conn.execute(sql, token)


async def get_token_by_access_hash(
conn: AsyncConnection, access_token_hash: str
) -> OidcToken | None:
sql: LiteralString = (
"SELECT * FROM oidc_tokens WHERE access_token_hash = %s AND NOT revoked"
)
async with conn.cursor(row_factory=class_row(OidcToken)) as cur:
await cur.execute(sql, (access_token_hash,))
return await cur.fetchone()


async def get_token_by_refresh_hash(
conn: AsyncConnection, refresh_token_hash: str
) -> OidcToken | None:
# revoked rows included on purpose — rotated-token replay must be
# distinguishable from an unknown token (reuse detection)
sql: LiteralString = "SELECT * FROM oidc_tokens WHERE refresh_token_hash = %s"
async with conn.cursor(row_factory=class_row(OidcToken)) as cur:
await cur.execute(sql, (refresh_token_hash,))
return await cur.fetchone()


async def revoke_token(conn: AsyncConnection, access_token_hash: str):
sql: LiteralString = (
"UPDATE oidc_tokens SET revoked = TRUE WHERE access_token_hash = %s"
)
await conn.execute(sql, (access_token_hash,))


async def revoke_all_for_grant(conn: AsyncConnection, client_id: str, user_sub: int):
sql: LiteralString = (
"UPDATE oidc_tokens SET revoked = TRUE WHERE client_id = %s AND user_sub = %s"
)
await conn.execute(sql, (client_id, user_sub))


async def revoke_for_terminal(conn: AsyncConnection, terminal_id: str):
"""Kill everything issued to one device, on un-pair.

Must run before the terminals row is deleted — the FK is ON DELETE SET NULL,
so afterwards nothing matches terminal_id any more.
"""
revoke_tokens: LiteralString = (
"UPDATE oidc_tokens SET revoked = TRUE WHERE terminal_id = %s"
)
burn_codes: LiteralString = (
"UPDATE oidc_codes SET redeemed = TRUE WHERE terminal_id = %s AND NOT redeemed"
)
await conn.execute(revoke_tokens, (terminal_id,))
await conn.execute(burn_codes, (terminal_id,))
Loading
Loading