decentralized and customizable links page on top of atproto

add some named tuples for db models

src/atproto2/__init__.py src/atproto/__init__.py
src/atproto2/atproto_identity.py src/atproto/atproto_identity.py
+19 -19
src/atproto2/atproto_oauth.py src/atproto/atproto_oauth.py
··· 9 9 from authlib.oauth2.rfc7636 import create_s256_code_challenge 10 10 from requests import Response 11 11 12 + from ..types import OAuthAuthRequest, OAuthSession 13 + 12 14 from .atproto_security import is_safe_url, hardened_http 13 15 14 16 ··· 195 197 # Completes the auth flow by sending an initial auth token request. 196 198 # Returns token response (dict) and DPoP nonce (str) 197 199 def initial_token_request( 198 - auth_request: dict[str, str], 200 + auth_request: OAuthAuthRequest, 199 201 code: str, 200 202 app_url: str, 201 203 client_secret_jwk: Key, 202 204 ) -> tuple[dict[str, str], str]: 203 - authserver_url = auth_request["authserver_iss"] 205 + authserver_url = auth_request.authserver_iss 204 206 205 207 # Re-fetch server metadata 206 208 authserver_meta = fetch_authserver_meta(authserver_url) ··· 219 221 "redirect_uri": redirect_uri, 220 222 "grant_type": "authorization_code", 221 223 "code": code, 222 - "code_verifier": auth_request["pkce_verifier"], 224 + "code_verifier": auth_request.pkce_verifier, 223 225 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 224 226 "client_assertion": client_assertion, 225 227 } 226 228 227 229 # Create DPoP header JWT, using the existing DPoP signing key for this account/session 228 230 token_url = authserver_meta["token_endpoint"] 229 - dpop_private_jwk = JsonWebKey.import_key( 230 - json.loads(auth_request["dpop_private_jwk"]) 231 - ) 232 - dpop_authserver_nonce = auth_request["dpop_authserver_nonce"] 231 + dpop_private_jwk = JsonWebKey.import_key(json.loads(auth_request.dpop_private_jwk)) 232 + dpop_authserver_nonce = auth_request.dpop_authserver_nonce 233 233 dpop_proof = authserver_dpop_jwt( 234 234 "POST", token_url, dpop_authserver_nonce, dpop_private_jwk 235 235 ) ··· 262 262 263 263 # Returns token response (dict) and DPoP nonce (str) 264 264 def refresh_token_request( 265 - user: dict, 265 + user: OAuthSession, 266 266 app_url: str, 267 267 client_secret_jwk: Key, 268 268 ) -> tuple[dict[str, str], str]: 269 - authserver_url = user["authserver_iss"] 269 + authserver_url = user.authserver_iss 270 270 271 271 # Re-fetch server metadata 272 272 authserver_meta = fetch_authserver_meta(authserver_url) ··· 282 282 params = { 283 283 "client_id": client_id, 284 284 "grant_type": "refresh_token", 285 - "refresh_token": user["refresh_token"], 285 + "refresh_token": user.refresh_token, 286 286 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 287 287 "client_assertion": client_assertion, 288 288 } 289 289 290 290 # Create DPoP header JWT, using the existing DPoP signing key for this account/session 291 291 token_url = authserver_meta["token_endpoint"] 292 - dpop_private_jwk = JsonWebKey.import_key(json.loads(user["dpop_private_jwk"])) 293 - dpop_authserver_nonce = user["dpop_authserver_nonce"] 292 + dpop_private_jwk = JsonWebKey.import_key(json.loads(user.dpop_private_jwk)) 293 + dpop_authserver_nonce = user.dpop_authserver_nonce 294 294 dpop_proof = authserver_dpop_jwt( 295 295 "POST", token_url, dpop_authserver_nonce, dpop_private_jwk 296 296 ) ··· 323 323 def pds_dpop_jwt( 324 324 method: str, 325 325 url: str, 326 - access_token: str, 327 - nonce: str, 326 + access_token: str | None, 327 + nonce: str | None, 328 328 dpop_private_jwk: Key, 329 329 ) -> str: 330 330 dpop_pub_jwk = json.loads(dpop_private_jwk.as_json(is_private=False)) ··· 352 352 def pds_authed_req( 353 353 method: str, 354 354 url: str, 355 - user: dict[str, str], 355 + user: OAuthSession, 356 356 db: sqlite3.Connection, 357 357 body: dict[str, Any] | None = None, 358 358 ) -> Response | None: 359 - dpop_private_jwk = JsonWebKey.import_key(json.loads(user["dpop_private_jwk"])) 360 - dpop_pds_nonce = user["dpop_pds_nonce"] 361 - access_token = user["access_token"] 359 + dpop_private_jwk = JsonWebKey.import_key(json.loads(user.dpop_private_jwk)) 360 + dpop_pds_nonce = user.dpop_pds_nonce 361 + access_token = user.access_token 362 362 363 363 response: Response | None = None 364 364 ··· 395 395 cur = db.cursor() 396 396 _ = cur.execute( 397 397 "UPDATE oauth_session SET dpop_pds_nonce = ? WHERE did = ?;", 398 - [dpop_pds_nonce, user["did"]], 398 + [dpop_pds_nonce, user.did], 399 399 ) 400 400 db.commit() 401 401 cur.close()
src/atproto2/atproto_security.py src/atproto/atproto_security.py
+2 -2
src/db.py
··· 1 - import sqlite3 2 - 3 1 from flask import Flask, g 2 + 3 + import sqlite3 4 4 5 5 6 6 def get_db(app: Flask) -> sqlite3.Connection:
+18 -16
src/main.py
··· 2 2 from typing import Any 3 3 import json 4 4 5 - from .atproto2 import PdsUrl, get_record, resolve_did_from_handle, resolve_pds_from_did 6 - from .atproto2.atproto_oauth import pds_authed_req 5 + from .atproto import PdsUrl, get_record, resolve_did_from_handle, resolve_pds_from_did 6 + from .atproto.atproto_oauth import pds_authed_req 7 7 from .db import close_db_connection, get_db, init_db 8 8 from .oauth import oauth 9 + from .types import OAuthSession 9 10 10 11 app = Flask(__name__) 11 12 _ = app.config.from_prefixed_env() ··· 20 21 21 22 @app.before_request 22 23 def load_user_to_context(): 24 + user: OAuthSession | None = None 23 25 did: str | None = session.get("user_did") 24 - if did is None: 25 - g.user = None 26 - else: 26 + if did is not None: 27 27 db = get_db(app) 28 - g.user = db.execute( 28 + row = db.execute( 29 29 "select * from oauth_session where did = ?", 30 30 (did,), 31 31 ).fetchone() 32 + user = OAuthSession(**row) 33 + g.user = user 32 34 33 35 34 - def get_user() -> dict[str, str] | None: 36 + def get_user() -> OAuthSession | None: 35 37 return g.user 36 38 37 39 ··· 90 92 if user is None: 91 93 return redirect("/login") 92 94 93 - did: str = user["did"] 94 - pds: str = user["pds_url"] 95 - handle: str | None = user["handle"] 95 + did: str = user.did 96 + pds: str = user.pds_url 97 + handle: str | None = user.handle 96 98 97 99 profile, from_bluesky = load_profile(pds, did, reload=True) 98 100 links = load_links(pds, did, reload=True) or [{"background": "#fa0"}] ··· 119 121 120 122 put_record( 121 123 user=user, 122 - pds=user["pds_url"], 123 - repo=user["did"], 124 + pds=user.pds_url, 125 + repo=user.did, 124 126 collection=f"{SCHEMA}.actor.profile", 125 127 rkey="self", 126 128 record={ ··· 159 161 160 162 put_record( 161 163 user=user, 162 - pds=user["pds_url"], 163 - repo=user["did"], 164 + pds=user.pds_url, 165 + repo=user.did, 164 166 collection=f"{SCHEMA}.actor.links", 165 167 rkey="self", 166 168 record={ ··· 212 214 213 215 214 216 def put_record( 215 - user: dict[str, str], 217 + user: OAuthSession, 216 218 pds: PdsUrl, 217 219 repo: str, 218 220 collection: str, ··· 246 248 if user is not None: 247 249 db = get_db(app) 248 250 cursor = db.cursor() 249 - _ = cursor.execute("delete from oauth_session where did = ?", (user["did"],)) 251 + _ = cursor.execute("delete from oauth_session where did = ?", (user.did,)) 250 252 db.commit() 251 253 cursor.close() 252 254 session.clear()
+43 -28
src/oauth.py
··· 4 4 5 5 import json 6 6 7 - from .atproto2.atproto_identity import is_valid_did, is_valid_handle 8 - from .atproto2.atproto_oauth import initial_token_request, send_par_auth_request 9 - from .atproto2.atproto_security import is_safe_url 10 - from .atproto2 import ( 7 + from .atproto.atproto_identity import is_valid_did, is_valid_handle 8 + from .atproto.atproto_oauth import initial_token_request, send_par_auth_request 9 + from .atproto.atproto_security import is_safe_url 10 + from .atproto import ( 11 11 pds_endpoint_from_doc, 12 12 resolve_authserver_from_pds, 13 13 resolve_authserver_meta, 14 14 resolve_identity, 15 15 ) 16 + from .types import OAuthAuthRequest 16 17 from .db import get_db 17 18 18 19 oauth = Blueprint("oauth", __name__, url_prefix="/oauth") 19 - 20 - 21 - oauth_auth_requests: dict[str, dict[str, str]] = {} 22 20 23 21 24 22 @oauth.get("/start") ··· 87 85 88 86 par_request_uri: str = resp.json()["request_uri"] 89 87 current_app.logger.debug(f"saving oauth_auth_request to DB state={state}") 90 - oauth_auth_requests[state] = { 91 - "authserver_iss": authserver_meta["issuer"], 92 - "did": did or "", # TODO: use actual typing 93 - "handle": handle or "", 94 - "pds_url": pds_url or "", 95 - "pkce_verifier": pkce_verifier, 96 - "scope": scope, 97 - "dpop_authserver_nonce": dpop_authserver_nonce, 98 - "dpop_private_jwk": dpop_private_jwk.as_json(is_private=True), 99 - } 88 + 89 + db = get_db(current_app) 90 + cursor = db.cursor() 91 + _ = cursor.execute( 92 + "insert or replace into oauth_auth_requests values (?, ?, ?, ?, ?, ?, ?, ?, ?)", 93 + ( 94 + state, 95 + authserver_meta["issuer"], 96 + did, 97 + handle, 98 + pds_url, 99 + pkce_verifier, 100 + scope, 101 + dpop_authserver_nonce, 102 + dpop_private_jwk.as_json(is_private=True), 103 + ), 104 + ) 105 + db.commit() 106 + cursor.close() 100 107 101 108 auth_endpoint = authserver_meta["authorization_endpoint"] 102 109 assert is_safe_url(auth_endpoint) ··· 110 117 authserver_iss = request.args["iss"] 111 118 authorization_code = request.args["code"] 112 119 113 - auth_request = oauth_auth_requests.get(state) 114 - if auth_request is None: 120 + db = get_db(current_app) 121 + cursor = db.cursor() 122 + 123 + row = cursor.execute( 124 + "select * from oauth_auth_requests where state = ?", (state,) 125 + ).fetchone() 126 + try: 127 + auth_request = OAuthAuthRequest(**row) 128 + except TypeError: 115 129 return redirect(url_for("page_login"), 303) 116 130 117 131 current_app.logger.debug(f"Deleting auth request for state={state}") 118 - _ = oauth_auth_requests.pop(state) 132 + _ = cursor.execute("delete from oauth_auth_requests where state = ?", (state,)) 133 + db.commit() 119 134 120 - assert auth_request["authserver_iss"] == authserver_iss 121 - # assert state ???? 135 + assert auth_request.authserver_iss == authserver_iss 136 + assert auth_request.state == state 122 137 123 138 app_url = request.url_root.replace("http://", "https://") 124 139 CLIENT_SECRET_JWK = JsonWebKey.import_key(current_app.config["CLIENT_SECRET_JWK"]) ··· 131 146 132 147 row = auth_request 133 148 134 - did = auth_request["did"] 135 - if row["did"]: 149 + did = auth_request.did 150 + if row.did: 136 151 # If we started with an account identifier, this is simple 137 - did, handle, pds_url = row["did"], row["handle"], row["pds_url"] 152 + did, handle, pds_url = row.did, row.handle, row.pds_url 138 153 assert tokens["sub"] == did 139 154 else: 140 155 did = tokens["sub"] ··· 149 164 authserver_url = resolve_authserver_from_pds(pds_url) 150 165 assert authserver_url == authserver_iss 151 166 152 - assert row["scope"] == tokens["scope"] 167 + assert row.scope == tokens["scope"] 153 168 154 169 current_app.logger.debug("storing user did and handle") 155 170 db = get_db(current_app) ··· 165 180 tokens["refresh_token"], 166 181 dpop_authserver_nonce, 167 182 None, 168 - auth_request["dpop_private_jwk"], 183 + auth_request.dpop_private_jwk, 169 184 ), 170 185 ) 171 186 db.commit() 172 187 cursor.close() 173 188 174 189 session["user_did"] = did 175 - session["user_handle"] = auth_request["handle"] 190 + session["user_handle"] = auth_request.handle 176 191 177 192 return redirect(url_for("page_login")) 178 193
+13 -1
src/schema.sql
··· 1 - create table if not exists oauth_session ( 1 + create table if not exists oauth_auth_requests ( 2 + state text not null primary key, 3 + authserver_iss text not null, 4 + did text, 5 + handle text, 6 + pds_url text, 7 + pkce_verifier text not null, 8 + scope text not null, 9 + dpop_authserver_nonce text not null, 10 + dpop_private_jwk text not null 11 + ) strict, without rowid; 12 + 13 + create table if not exists oauth_sessions ( 2 14 did text not null primary key, 3 15 handle text, 4 16 pds_url text not null,
+25
src/types.py
··· 1 + from typing import NamedTuple 2 + 3 + 4 + class OAuthAuthRequest(NamedTuple): 5 + state: str 6 + authserver_iss: str 7 + did: str | None 8 + handle: str | None 9 + pds_url: str | None 10 + pkce_verifier: str 11 + scope: str 12 + dpop_authserver_nonce: str 13 + dpop_private_jwk: str 14 + 15 + 16 + class OAuthSession(NamedTuple): 17 + did: str 18 + handle: str | None 19 + pds_url: str 20 + authserver_iss: str 21 + access_token: str | None 22 + refresh_token: str | None 23 + dpop_authserver_nonce: str 24 + dpop_pds_nonce: str | None 25 + dpop_private_jwk: str