decentralized and customizable links page on top of atproto

use async await more

Changed files
+88 -75
src
+33 -30
src/atproto/__init__.py
··· 25 25 return regex_match(DID_REGEX, did) is not None 26 26 27 27 28 - def resolve_identity( 28 + async def resolve_identity( 29 29 query: str, 30 30 didkv: KV = nokv, 31 31 ) -> tuple[str, str, dict[str, Any]] | None: ··· 36 36 did = resolve_did_from_handle(handle, didkv) 37 37 if not did: 38 38 return None 39 - doc = resolve_doc_from_did(did) 39 + doc = await resolve_doc_from_did(did) 40 40 if not doc: 41 41 return None 42 42 handles = handles_from_doc(doc) ··· 46 46 47 47 if is_valid_did(query): 48 48 did = query 49 - doc = resolve_doc_from_did(did) 49 + doc = await resolve_doc_from_did(did) 50 50 if not doc: 51 51 return None 52 52 handle = handle_from_doc(doc) ··· 120 120 return None 121 121 122 122 123 - def resolve_pds_from_did( 123 + async def resolve_pds_from_did( 124 124 did: DID, 125 125 kv: KV = nokv, 126 126 reload: bool = False, ··· 130 130 print(f"returning cached pds for {did}") 131 131 return pds 132 132 133 - doc = resolve_doc_from_did(did) 133 + doc = await resolve_doc_from_did(did) 134 134 if doc is None: 135 135 return None 136 136 pds = doc["service"][0]["serviceEndpoint"] ··· 141 141 return pds 142 142 143 143 144 - def resolve_doc_from_did( 144 + async def resolve_doc_from_did( 145 145 did: DID, 146 146 directory: str = PLC_DIRECTORY, 147 147 ) -> dict[str, Any] | None: 148 - if did.startswith("did:plc:"): 149 - response = httpx.get(f"{directory}/{did}") 150 - if response.is_success: 151 - return response.json() 152 - return None 148 + async with httpx.AsyncClient() as client: 149 + if did.startswith("did:plc:"): 150 + response = await client.get(f"{directory}/{did}") 151 + if response.is_success: 152 + return response.json() 153 + return None 153 154 154 - if did.startswith("did:web:"): 155 - # TODO: resolve did:web 156 - return None 155 + if did.startswith("did:web:"): 156 + # TODO: resolve did:web 157 + return None 157 158 158 159 return None 159 160 160 161 161 - def resolve_authserver_from_pds( 162 + async def resolve_authserver_from_pds( 162 163 pds_url: PdsUrl, 163 164 kv: KV = nokv, 164 165 reload: bool = False, ··· 172 173 173 174 assert is_safe_url(pds_url) 174 175 endpoint = f"{pds_url}/.well-known/oauth-protected-resource" 175 - response = httpx.get(endpoint) 176 - if response.status_code != 200: 177 - return None 178 - parsed: dict[str, list[str]] = response.json() 179 - authserver_url = parsed["authorization_servers"][0] 180 - print(f"caching authserver {authserver_url} for PDS {pds_url}") 181 - kv.set(pds_url, value=authserver_url) 182 - return authserver_url 176 + async with httpx.AsyncClient() as client: 177 + response = await client.get(endpoint) 178 + if response.status_code != 200: 179 + return None 180 + parsed: dict[str, list[str]] = response.json() 181 + authserver_url = parsed["authorization_servers"][0] 182 + print(f"caching authserver {authserver_url} for PDS {pds_url}") 183 + kv.set(pds_url, value=authserver_url) 184 + return authserver_url 183 185 184 186 185 - def fetch_authserver_meta(authserver_url: str) -> dict[str, str] | None: 187 + async def fetch_authserver_meta(authserver_url: str) -> dict[str, str] | None: 186 188 """Returns metadata from the authserver""" 187 189 assert is_safe_url(authserver_url) 188 190 endpoint = f"{authserver_url}/.well-known/oauth-authorization-server" 189 - response = httpx.get(endpoint) 190 - if not response.is_success: 191 - return None 192 - meta: dict[str, Any] = response.json() 193 - assert is_valid_authserver_meta(meta, authserver_url) 194 - return meta 191 + async with httpx.AsyncClient() as client: 192 + response = await client.get(endpoint) 193 + if not response.is_success: 194 + return None 195 + meta: dict[str, Any] = response.json() 196 + assert is_valid_authserver_meta(meta, authserver_url) 197 + return meta 195 198 196 199 197 200 async def get_record(
+27 -22
src/atproto/oauth.py
··· 1 1 from typing import Any, Callable, NamedTuple 2 2 import time 3 3 import json 4 - from authlib.jose import JsonWebKey, Key 4 + from authlib.jose import JsonWebKey, Key, jwt 5 5 from authlib.common.security import generate_token 6 - from authlib.jose import jwt 7 6 from authlib.oauth2.rfc7636 import create_s256_code_challenge 8 7 from httpx import Response 9 8 ··· 26 25 27 26 # Prepares and sends a pushed auth request (PAR) via HTTP POST to the Authorization Server. 28 27 # Returns "state" id HTTP response on success, without checking HTTP response status 29 - def send_par_auth_request( 28 + async def send_par_auth_request( 30 29 authserver_url: str, 31 30 authserver_meta: dict[str, str], 32 31 login_hint: str | None, ··· 71 70 72 71 # IMPORTANT: Pushed Authorization Request URL is untrusted input, SSRF mitigations are needed 73 72 assert is_safe_url(par_url) 74 - with hardened_http.get_session() as sess: 75 - resp = sess.post( 73 + async with hardened_http.get_session() as session: 74 + resp = await session.post( 76 75 par_url, 77 76 headers={ 78 77 "Content-Type": "application/x-www-form-urlencoded", ··· 88 87 dpop_proof = _authserver_dpop_jwt( 89 88 "POST", par_url, dpop_authserver_nonce, dpop_private_jwk 90 89 ) 91 - with hardened_http.get_session() as sess: 92 - resp = sess.post( 90 + async with hardened_http.get_session() as session: 91 + resp = await session.post( 93 92 par_url, 94 93 headers={ 95 94 "Content-Type": "application/x-www-form-urlencoded", ··· 104 103 # Completes the auth flow by sending an initial auth token request. 105 104 # Returns token response (OAuthTokens) and DPoP nonce (str) 106 105 # IMPORTANT: the 'tokens.sub' field must be verified against the original request by code calling this function. 107 - def initial_token_request( 106 + async def initial_token_request( 108 107 auth_request: OAuthAuthRequest, 109 108 code: str, 110 109 app_url: str, ··· 113 112 authserver_url = auth_request.authserver_iss 114 113 115 114 # Re-fetch server metadata 116 - authserver_meta = fetch_authserver_meta(authserver_url) 115 + authserver_meta = await fetch_authserver_meta(authserver_url) 117 116 if not authserver_meta: 118 117 raise Exception("missing authserver meta") 119 118 ··· 146 145 147 146 # IMPORTANT: Token URL is untrusted input, SSRF mitigations are needed 148 147 assert is_safe_url(token_url) 149 - with hardened_http.get_session() as sess: 150 - resp = sess.post(token_url, data=params, headers={"DPoP": dpop_proof}) 148 + async with hardened_http.get_session() as session: 149 + resp = await session.post(token_url, data=params, headers={"DPoP": dpop_proof}) 151 150 152 151 # Handle DPoP missing/invalid nonce error by retrying with server-provided nonce 153 152 if resp.status_code == 400 and resp.json()["error"] == "use_dpop_nonce": ··· 157 156 dpop_proof = _authserver_dpop_jwt( 158 157 "POST", token_url, dpop_authserver_nonce, dpop_private_jwk 159 158 ) 160 - with hardened_http.get_session() as sess: 161 - resp = sess.post(token_url, data=params, headers={"DPoP": dpop_proof}) 159 + async with hardened_http.get_session() as session: 160 + resp = await session.post( 161 + token_url, 162 + data=params, 163 + headers={"DPoP": dpop_proof}, 164 + ) 162 165 163 166 resp.raise_for_status() 164 167 token_body = resp.json() ··· 168 171 169 172 170 173 # Returns token response (OAuthTokens) and DPoP nonce (str) 171 - def refresh_token_request( 174 + async def refresh_token_request( 172 175 user: OAuthSession, 173 176 app_url: str, 174 177 client_secret_jwk: Key, ··· 176 179 authserver_url = user.authserver_iss 177 180 178 181 # Re-fetch server metadata 179 - authserver_meta = fetch_authserver_meta(authserver_url) 182 + authserver_meta = await fetch_authserver_meta(authserver_url) 180 183 if not authserver_meta: 181 184 raise Exception("missing authserver meta") 182 185 ··· 206 209 207 210 # IMPORTANT: Token URL is untrusted input, SSRF mitigations are needed 208 211 assert is_safe_url(token_url) 209 - with hardened_http.get_session() as sess: 210 - resp = sess.post(token_url, data=params, headers={"DPoP": dpop_proof}) 212 + async with hardened_http.get_session() as session: 213 + resp = await session.post(token_url, data=params, headers={"DPoP": dpop_proof}) 211 214 212 215 # Handle DPoP missing/invalid nonce error by retrying with server-provided nonce 213 216 if resp.status_code == 400 and resp.json()["error"] == "use_dpop_nonce": ··· 217 220 dpop_proof = _authserver_dpop_jwt( 218 221 "POST", token_url, dpop_authserver_nonce, dpop_private_jwk 219 222 ) 220 - with hardened_http.get_session() as sess: 221 - resp = sess.post(token_url, data=params, headers={"DPoP": dpop_proof}) 223 + async with hardened_http.get_session() as session: 224 + resp = await session.post( 225 + token_url, data=params, headers={"DPoP": dpop_proof} 226 + ) 222 227 223 228 if resp.status_code not in [200, 201]: 224 229 print(f"Token Refresh Error: {resp.json()}") ··· 232 237 233 238 # Helper to demonstrate making a request (HTTP GET or POST) to the user's PDS ("Resource Server" in OAuth terminology) using DPoP and access token. 234 239 # This method returns a 'requests' reponse, without checking status code. 235 - def pds_authed_req( 240 + async def pds_authed_req( 236 241 method: str, 237 242 url: str, 238 243 user: OAuthSession, ··· 255 260 dpop_private_jwk, 256 261 ) 257 262 258 - with hardened_http.get_session() as sess: 259 - response = sess.post( 263 + async with hardened_http.get_session() as session: 264 + response = await session.post( 260 265 url, 261 266 headers={ 262 267 "Authorization": f"DPoP {access_token}",
+10 -8
src/main.py
··· 61 61 return render_template("error.html", message="profile not found"), 404 62 62 63 63 kv = KV(app, "pds_from_did") 64 - pds = resolve_pds_from_did(did, kv, reload=reload) 64 + pds = await resolve_pds_from_did(did, kv, reload=reload) 65 65 if pds is None: 66 66 return render_template("error.html", message="pds not found"), 404 67 67 (profile, _), links = await asyncio.gather( ··· 126 126 127 127 128 128 @app.post("/editor/profile") 129 - def post_editor_profile(): 129 + async def post_editor_profile(): 130 130 user = get_user() 131 131 if user is None: 132 132 return redirect("/login", 303) ··· 136 136 if not display_name: 137 137 return redirect("/editor", 303) 138 138 139 - put_record( 139 + await put_record( 140 140 user=user, 141 141 pds=user.pds_url, 142 142 repo=user.did, ··· 153 153 154 154 155 155 @app.post("/editor/links") 156 - def post_editor_links(): 156 + async def post_editor_links(): 157 157 user = get_user() 158 158 if user is None: 159 159 return redirect("/login", 303) ··· 176 176 link["detail"] = detail 177 177 links.append(link) 178 178 179 - put_record( 179 + await put_record( 180 180 user=user, 181 181 pds=user.pds_url, 182 182 repo=user.did, ··· 221 221 async def load_profile( 222 222 pds: str, 223 223 did: str, 224 + fallback_with_bluesky: bool = True, 224 225 reload: bool = False, 225 226 ) -> tuple[tuple[str, str] | None, bool]: 226 227 kv = KV(app, "profile_from_did") ··· 232 233 233 234 from_bluesky = False 234 235 record = await get_record(pds, did, f"{SCHEMA}.actor.profile", "self") 235 - if record is None: 236 + if record is None and fallback_with_bluesky: 236 237 record = await get_record(pds, did, "app.bsky.actor.profile", "self") 237 238 from_bluesky = True 238 239 if record is None: ··· 244 245 return profile, from_bluesky 245 246 246 247 247 - def put_record( 248 + # TODO: move to .atproto 249 + async def put_record( 248 250 user: OAuthSession, 249 251 pds: PdsUrl, 250 252 repo: str, ··· 264 266 session_ = user._replace(dpop_pds_nonce=nonce) 265 267 save_auth_session(session, session_) 266 268 267 - response = pds_authed_req( 269 + response = await pds_authed_req( 268 270 method="POST", 269 271 url=endpoint, 270 272 body=body,
+15 -12
src/oauth.py
··· 24 24 25 25 26 26 @oauth.get("/start") 27 - def oauth_start(): 27 + async def oauth_start(): 28 28 # Identity 29 29 username = request.args.get("username") or request.args.get("authserver") 30 30 if not username: ··· 36 36 if is_valid_handle(username) or is_valid_did(username): 37 37 login_hint = username 38 38 kv = KV(db, "did_from_handle") 39 - identity = resolve_identity(username, didkv=kv) 39 + identity = await resolve_identity(username, didkv=kv) 40 40 if identity is None: 41 41 return "couldnt resolve identity", 500 42 42 did, handle, doc = identity ··· 44 44 if not pds_url: 45 45 return "pds not found", 404 46 46 current_app.logger.debug(f"account PDS: {pds_url}") 47 - authserver_url = resolve_authserver_from_pds(pds_url, pdskv) 47 + authserver_url = await resolve_authserver_from_pds(pds_url, pdskv) 48 48 if not authserver_url: 49 49 return "authserver not found", 404 50 50 51 51 elif username.startswith("https://") and is_safe_url(username): 52 52 did, handle, pds_url = None, None, None 53 53 login_hint = None 54 - authserver_url = resolve_authserver_from_pds(username, pdskv) or username 54 + authserver_url = await resolve_authserver_from_pds(username, pdskv) or username 55 55 56 56 else: 57 57 return "not a valid handle, did or auth server", 400 58 58 59 59 current_app.logger.debug(f"Authserver: {authserver_url}") 60 60 assert is_safe_url(authserver_url) 61 - authserver_meta = fetch_authserver_meta(authserver_url) 61 + authserver_meta = await fetch_authserver_meta(authserver_url) 62 62 if not authserver_meta: 63 63 return "no authserver meta", 404 64 64 ··· 77 77 78 78 CLIENT_SECRET_JWK = JsonWebKey.import_key(current_app.config["CLIENT_SECRET_JWK"]) 79 79 80 - pkce_verifier, state, dpop_authserver_nonce, resp = send_par_auth_request( 80 + pkce_verifier, state, dpop_authserver_nonce, resp = await send_par_auth_request( 81 81 authserver_url, 82 82 authserver_meta, 83 83 login_hint, ··· 87 87 CLIENT_SECRET_JWK, 88 88 dpop_private_jwk, 89 89 ) 90 + 90 91 if resp.status_code == 400: 91 - current_app.logger.debug(f"PAR HTTP 400: {resp.json()}") 92 - resp.raise_for_status() 92 + current_app.logger.debug("PAR request returned error 400") 93 + current_app.logger.debug(resp.text) 94 + return redirect(url_for("page_login"), 303) 95 + _ = resp.raise_for_status() 93 96 94 97 par_request_uri: str = resp.json()["request_uri"] 95 98 current_app.logger.debug(f"saving oauth_auth_request to DB state={state}") ··· 114 117 115 118 116 119 @oauth.get("/callback") 117 - def oauth_callback(): 120 + async def oauth_callback(): 118 121 state = request.args["state"] 119 122 authserver_iss = request.args["iss"] 120 123 authorization_code = request.args["code"] ··· 131 134 132 135 app_url = request.url_root.replace("http://", "https://") 133 136 CLIENT_SECRET_JWK = JsonWebKey.import_key(current_app.config["CLIENT_SECRET_JWK"]) 134 - tokens, dpop_authserver_nonce = initial_token_request( 137 + tokens, dpop_authserver_nonce = await initial_token_request( 135 138 auth_request, 136 139 authorization_code, 137 140 app_url, ··· 151 154 else: 152 155 did = tokens.sub 153 156 assert is_valid_did(did) 154 - identity = resolve_identity(did, didkv=didkv) 157 + identity = await resolve_identity(did, didkv=didkv) 155 158 if not identity: 156 159 return "could not resolve identity", 500 157 160 did, handle, did_doc = identity 158 161 pds_url = pds_endpoint_from_doc(did_doc) 159 162 if not pds_url: 160 163 return "could not resolve pds", 500 161 - authserver_url = resolve_authserver_from_pds(pds_url, authserverkv) 164 + authserver_url = await resolve_authserver_from_pds(pds_url, authserverkv) 162 165 assert authserver_url == authserver_iss 163 166 164 167 assert row.scope == tokens.scope
+2 -2
src/security.py
··· 30 30 31 31 32 32 class HardenedHttp: 33 - def get_session(self) -> httpx.Client: 34 - return httpx.Client( 33 + def get_session(self) -> httpx.AsyncClient: 34 + return httpx.AsyncClient( 35 35 timeout=httpx.Timeout(20, connect=5), 36 36 follow_redirects=False, 37 37 headers={
+1 -1
src/templates/login.html
··· 19 19 <form action="{{ url_for('auth_login') }}" method="post"> 20 20 <label> 21 21 <span>Handle</span> 22 - <input type="text" name="username" placeholder="username.example.com" autocapitalize="off" autocomplete="off" spellcheck="false" required /> 22 + <input type="text" name="username" placeholder="username.example.com" autocapitalize="off" spellcheck="false" required /> 23 23 </label> 24 24 <span class="caption"> 25 25 Use your AT Protocol handle to log in.