-
Notifications
You must be signed in to change notification settings - Fork 2
Phase 2a: embedded OIDC provider (authorize/token/userinfo/jwks/discovery) #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 b122372
tests, meta: build the mock app store zips at test time
ClaydeCode 02bd02d
Merge pull request #209 from FreeshardBase/chore/clayde/mock-app-stor…
max-tet f19a068
feat(oidc): phase-2a embedded OIDC provider — productionized from the…
ClaydeCode f849c14
fix(oidc): adapt provider to numeric user ids from phase-1 review
ClaydeCode c3374d5
fix(oidc): ShardUser builds from User model (db module returns object…
ClaydeCode 510a65f
feat(oidc): security hardening from OIDC review — S256-only PKCE, ato…
ClaydeCode 3b377b1
fix(oidc): build the request URL from the shard's domain instead of t…
ClaydeCode a9ed08c
feat(oidc): gate the provider behind an oidc.enabled config flag
ClaydeCode 57ce06b
feat(oidc): bind grants to the authorizing terminal and emit sid
ClaydeCode 0858c2f
fix(db): renumber the oidc migration to 0004
ClaydeCode 7c60441
service: return the stored OIDC client row from register_client
ClaydeCode 5b9168a
web: say what the terminal_id assignment does, not what it implies
ClaydeCode dd79d02
service, database: guard the sync-to-async bridge and move with_conn
ClaydeCode 7e4dcad
web: rebuild the public URL from the forwarded headers
ClaydeCode af09b65
service, database: model-typed OIDC rows, an FK to apps, no logout uri
ClaydeCode cdde281
service: align the bridge timeout with the connection pool timeout
ClaydeCode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,6 +82,7 @@ celerybeat-schedule | |
|
|
||
| # dotenv | ||
| .env | ||
| !tests/mock_app_store/*/.env | ||
|
|
||
| # virtualenv | ||
| .venv/ | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| 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); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
max-tet marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
|
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,)) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.