feat: OIDC Identity Provider — Phase 1 endpoints
Add OIDC/OAuth2 endpoints to bincio-auth so it acts as a full IdP: GET /.well-known/openid-configuration GET /.well-known/jwks.json GET /oauth2/authorize (auth-code flow, redirects to /login/ if no session) POST /oauth2/token (exchanges code for RS256 id_token; PKCE supported) GET /oauth2/userinfo (Bearer token → profile claims) Infrastructure: - oauth2_clients + oauth2_codes tables in db.py with CRUD helpers - RS256 sign/verify helpers in tokens.py (create_id_token, get_jwks) - oidc_private_key_pem / oidc_issuer state + _issue_id_token in deps.py - serve_cmd reads BINCIO_OIDC_PRIVATE_KEY_FILE / BINCIO_OIDC_ISSUER env vars - `bincio-auth client add/list` commands for managing OAuth2 clients
This commit is contained in:
@@ -74,9 +74,33 @@ CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
PRIMARY KEY (handle, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth2_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_secret TEXT,
|
||||
name TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL DEFAULT 'openid profile',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth2_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
handle TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
nonce TEXT,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle);
|
||||
CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by);
|
||||
CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle);
|
||||
CREATE INDEX IF NOT EXISTS oauth2_codes_client ON oauth2_codes(client_id);
|
||||
CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle);
|
||||
"""
|
||||
|
||||
@@ -488,3 +512,83 @@ def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool:
|
||||
)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── OAuth2 clients ────────────────────────────────────────────────────────────
|
||||
|
||||
import json as _json
|
||||
|
||||
|
||||
def get_oauth2_client(db: sqlite3.Connection, client_id: str) -> dict | None:
|
||||
row = db.execute(
|
||||
"SELECT client_id, client_secret, name, redirect_uris, scopes FROM oauth2_clients WHERE client_id = ?",
|
||||
(client_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"client_id": row["client_id"],
|
||||
"client_secret": row["client_secret"],
|
||||
"name": row["name"],
|
||||
"redirect_uris": _json.loads(row["redirect_uris"]),
|
||||
"scopes": row["scopes"],
|
||||
}
|
||||
|
||||
|
||||
def upsert_oauth2_client(
|
||||
db: sqlite3.Connection,
|
||||
client_id: str,
|
||||
client_secret: str | None,
|
||||
name: str,
|
||||
redirect_uris: list[str],
|
||||
scopes: str = "openid profile",
|
||||
) -> None:
|
||||
db.execute(
|
||||
"INSERT INTO oauth2_clients (client_id, client_secret, name, redirect_uris, scopes, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(client_id) DO UPDATE SET "
|
||||
"client_secret=excluded.client_secret, name=excluded.name, "
|
||||
"redirect_uris=excluded.redirect_uris, scopes=excluded.scopes",
|
||||
(client_id, client_secret, name, _json.dumps(redirect_uris), scopes, int(time.time())),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── OAuth2 authorization codes ────────────────────────────────────────────────
|
||||
|
||||
_CODE_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
def create_oauth2_code(
|
||||
db: sqlite3.Connection,
|
||||
client_id: str,
|
||||
handle: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
nonce: str | None = None,
|
||||
code_challenge: str | None = None,
|
||||
code_challenge_method: str | None = None,
|
||||
) -> str:
|
||||
code = secrets.token_urlsafe(32)
|
||||
now = int(time.time())
|
||||
db.execute(
|
||||
"INSERT INTO oauth2_codes "
|
||||
"(code, client_id, handle, redirect_uri, scope, nonce, code_challenge, code_challenge_method, created_at, expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(code, client_id, handle, redirect_uri, scope, nonce,
|
||||
code_challenge, code_challenge_method, now, now + _CODE_TTL),
|
||||
)
|
||||
db.commit()
|
||||
return code
|
||||
|
||||
|
||||
def get_oauth2_code(db: sqlite3.Connection, code: str) -> dict | None:
|
||||
row = db.execute("SELECT * FROM oauth2_codes WHERE code = ?", (code,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def use_oauth2_code(db: sqlite3.Connection, code: str) -> None:
|
||||
db.execute("UPDATE oauth2_codes SET used_at = ? WHERE code = ?", (int(time.time()), code))
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user