decentralized and customizable links page on top of atproto

store OAuth session in client side cookie

Changed files
+104 -124
src
+8 -20
src/atproto/oauth.py
··· 1 - import sqlite3 2 - from typing import Any, NamedTuple 1 + from typing import Any, Callable, NamedTuple 3 2 import time 4 3 import json 5 4 from authlib.jose import JsonWebKey, Key ··· 20 19 refresh_token: str 21 20 scope: str 22 21 sub: str 22 + # only for parsing 23 + token_type: str | None 24 + expires_in: int | None 23 25 24 26 25 27 # Prepares and sends a pushed auth request (PAR) via HTTP POST to the Authorization Server. ··· 160 162 161 163 resp.raise_for_status() 162 164 token_body = resp.json() 163 - try: 164 - tokens = OAuthTokens(**token_body) 165 - except TypeError: 166 - raise Exception("invalid token body") 165 + tokens = OAuthTokens(**token_body) 167 166 168 167 return tokens, dpop_authserver_nonce 169 168 ··· 226 225 227 226 resp.raise_for_status() 228 227 token_body = resp.json() 229 - try: 230 - tokens = OAuthTokens(**token_body) 231 - except TypeError: 232 - raise Exception("invalid token body") 228 + tokens = OAuthTokens(**token_body) 233 229 234 230 return tokens, dpop_authserver_nonce 235 231 ··· 240 236 method: str, 241 237 url: str, 242 238 user: OAuthSession, 243 - db: sqlite3.Connection, 239 + update_dpop_pds_nonce: Callable[[str], None], 244 240 body: dict[str, Any] | None = None, 245 241 ) -> Response | None: 246 242 dpop_private_jwk = JsonWebKey.import_key(json.loads(user.dpop_private_jwk)) ··· 275 271 response.status_code in [400, 401] 276 272 and response.json()["error"] == "use_dpop_nonce" 277 273 ): 278 - # print(resp.headers) 279 274 dpop_pds_nonce = response.headers["DPoP-Nonce"] 280 275 print(f"retrying with new PDS DPoP nonce: {dpop_pds_nonce}") 281 - # update session database with new nonce 282 - cur = db.cursor() 283 - _ = cur.execute( 284 - "UPDATE oauth_session SET dpop_pds_nonce = ? WHERE did = ?;", 285 - [dpop_pds_nonce, user.did], 286 - ) 287 - db.commit() 288 - cur.close() 276 + update_dpop_pds_nonce(dpop_pds_nonce) 289 277 continue 290 278 break 291 279
+20 -29
src/main.py
··· 4 4 5 5 from .atproto import PdsUrl, get_record, resolve_did_from_handle, resolve_pds_from_did 6 6 from .atproto.oauth import pds_authed_req 7 - from .db import close_db_connection, get_db, init_db 8 - from .oauth import oauth 7 + from .db import close_db_connection, init_db 8 + from .oauth import get_auth_session, oauth, save_auth_session 9 9 from .types import OAuthSession 10 10 11 11 app = Flask(__name__) ··· 21 21 22 22 @app.before_request 23 23 def load_user_to_context(): 24 - user: OAuthSession | None = None 25 - did: str | None = session.get("user_did") 26 - if did is not None: 27 - db = get_db(app) 28 - row = db.execute( 29 - "select * from oauth_session where did = ?", 30 - (did,), 31 - ).fetchone() 32 - user = OAuthSession(**row) 33 - g.user = user 24 + g.user = get_auth_session(session) 34 25 35 26 36 27 def get_user() -> OAuthSession | None: ··· 84 75 if not username: 85 76 return redirect(url_for("page_login"), 303) 86 77 return redirect(url_for("oauth.oauth_start", username=username), 303) 78 + 79 + 80 + @app.route("/auth/logout") 81 + def auth_logout(): 82 + session.clear() 83 + return redirect("/", 303) 87 84 88 85 89 86 @app.get("/editor") ··· 174 171 return redirect("/editor", 303) 175 172 176 173 174 + @app.get("/terms") 175 + def page_terms(): 176 + return "come back soon" 177 + 178 + 177 179 def load_links(pds: str, did: str, reload: bool = False) -> list[dict[str, str]] | None: 178 180 if did in links and not reload: 179 181 app.logger.debug(f"returning cached links for {did}") ··· 228 230 "rkey": rkey, 229 231 "record": record, 230 232 } 233 + 234 + def update_dpop_pds_nonce(nonce: str): 235 + session_ = user._replace(dpop_pds_nonce=nonce) 236 + save_auth_session(session, session_) 237 + 231 238 response = pds_authed_req( 232 239 method="POST", 233 240 url=endpoint, 234 241 body=body, 235 242 user=user, 236 - db=get_db(app), 243 + update_dpop_pds_nonce=update_dpop_pds_nonce, 237 244 ) 238 245 if not response or not response.ok: 239 246 app.logger.warning("PDS HTTP ERROR") 240 - 241 - 242 - # AUTH 243 - 244 - 245 - @app.route("/auth/logout") 246 - def auth_logout(): 247 - user = get_user() 248 - if user is not None: 249 - db = get_db(app) 250 - cursor = db.cursor() 251 - _ = cursor.execute("delete from oauth_session where did = ?", (user.did,)) 252 - db.commit() 253 - cursor.close() 254 - session.clear() 255 - return redirect("/", 303)
+75 -52
src/oauth.py
··· 1 + from typing import NamedTuple 1 2 from authlib.jose import JsonWebKey, Key 2 3 from flask import Blueprint, current_app, jsonify, redirect, request, session, url_for 4 + from flask.sessions import SessionMixin 3 5 from urllib.parse import urlencode 4 6 5 7 import json ··· 14 16 ) 15 17 from .atproto.oauth import initial_token_request, send_par_auth_request 16 18 from .security import is_safe_url 17 - from .types import OAuthAuthRequest 18 - from .db import get_db 19 + from .types import OAuthAuthRequest, OAuthSession 19 20 20 21 oauth = Blueprint("oauth", __name__, url_prefix="/oauth") 21 22 ··· 87 88 par_request_uri: str = resp.json()["request_uri"] 88 89 current_app.logger.debug(f"saving oauth_auth_request to DB state={state}") 89 90 90 - db = get_db(current_app) 91 - cursor = db.cursor() 92 - _ = cursor.execute( 93 - "insert or replace into oauth_auth_requests values (?, ?, ?, ?, ?, ?, ?, ?, ?)", 94 - ( 95 - state, 96 - authserver_meta["issuer"], 97 - did, 98 - handle, 99 - pds_url, 100 - pkce_verifier, 101 - scope, 102 - dpop_authserver_nonce, 103 - dpop_private_jwk.as_json(is_private=True), 104 - ), 91 + oauth_request = OAuthAuthRequest( 92 + state, 93 + authserver_meta["issuer"], 94 + did, 95 + handle, 96 + pds_url, 97 + pkce_verifier, 98 + scope, 99 + dpop_authserver_nonce, 100 + dpop_private_jwk.as_json(is_private=True), 105 101 ) 106 - db.commit() 107 - cursor.close() 102 + save_auth_request(session, oauth_request) 108 103 109 104 auth_endpoint = authserver_meta["authorization_endpoint"] 110 105 assert is_safe_url(auth_endpoint) ··· 118 113 authserver_iss = request.args["iss"] 119 114 authorization_code = request.args["code"] 120 115 121 - db = get_db(current_app) 122 - cursor = db.cursor() 123 - 124 - row = cursor.execute( 125 - "select * from oauth_auth_requests where state = ?", (state,) 126 - ).fetchone() 127 - try: 128 - auth_request = OAuthAuthRequest(**row) 129 - except TypeError: 116 + auth_request = get_auth_request(session) 117 + if not auth_request: 130 118 return redirect(url_for("page_login"), 303) 131 119 132 120 current_app.logger.debug(f"Deleting auth request for state={state}") 133 - _ = cursor.execute("delete from oauth_auth_requests where state = ?", (state,)) 134 - db.commit() 121 + delete_auth_request(session) 135 122 136 123 assert auth_request.authserver_iss == authserver_iss 137 124 assert auth_request.state == state ··· 147 134 148 135 row = auth_request 149 136 150 - did = auth_request.did 151 137 if row.did: 152 138 # If we started with an account identifier, this is simple 153 139 did, handle, pds_url = row.did, row.handle, row.pds_url ··· 166 152 assert authserver_url == authserver_iss 167 153 168 154 assert row.scope == tokens.scope 155 + assert pds_url is not None 169 156 170 - current_app.logger.debug("storing user did and handle") 171 - db = get_db(current_app) 172 - cursor = db.cursor() 173 - _ = cursor.execute( 174 - "insert or replace into oauth_session values (?, ?, ?, ?, ?, ?, ?, ?, ?)", 175 - ( 176 - did, 177 - handle, 178 - pds_url, 179 - authserver_iss, 180 - tokens.access_token, 181 - tokens.refresh_token, 182 - dpop_authserver_nonce, 183 - None, 184 - auth_request.dpop_private_jwk, 185 - ), 157 + current_app.logger.debug("storing user oauth session") 158 + oauth_session = OAuthSession( 159 + did, 160 + handle, 161 + pds_url, 162 + authserver_iss, 163 + tokens.access_token, 164 + tokens.refresh_token, 165 + dpop_authserver_nonce, 166 + None, 167 + auth_request.dpop_private_jwk, 186 168 ) 187 - db.commit() 188 - cursor.close() 189 - 190 - session["user_did"] = did 191 - session["user_handle"] = auth_request.handle 169 + save_auth_session(session, oauth_session) 192 170 193 171 return redirect(url_for("page_login")) 194 172 ··· 221 199 CLIENT_SECRET_JWK = JsonWebKey.import_key(current_app.config["CLIENT_SECRET_JWK"]) 222 200 CLIENT_PUB_JWK = json.loads(CLIENT_SECRET_JWK.as_json(is_private=False)) 223 201 return jsonify({"keys": [CLIENT_PUB_JWK]}) 202 + 203 + 204 + # Session storage 205 + 206 + 207 + def save_auth_request(session: SessionMixin, request: OAuthAuthRequest): 208 + return _set_into_session(session, "oauth_auth_request", request) 209 + 210 + 211 + def save_auth_session(session: SessionMixin, auth_session: OAuthSession): 212 + return _set_into_session(session, "oauth_auth_session", auth_session) 213 + 214 + 215 + def delete_auth_request(session: SessionMixin): 216 + return _delete_from_session(session, "oauth_auth_request") 217 + 218 + 219 + def delete_auth_session(session: SessionMixin): 220 + return _delete_from_session(session, "oauth_auth_session") 221 + 222 + 223 + def get_auth_request(session: SessionMixin) -> OAuthAuthRequest | None: 224 + try: 225 + return OAuthAuthRequest(**session["oauth_auth_request"]) 226 + except TypeError as exception: 227 + current_app.logger.debug("unable to load oauth_auth_request") 228 + current_app.logger.debug(exception) 229 + return None 230 + 231 + 232 + def get_auth_session(session: SessionMixin) -> OAuthSession | None: 233 + try: 234 + return OAuthSession(**session["oauth_auth_session"]) 235 + except TypeError as exception: 236 + current_app.logger.debug("unable to load oauth_auth_session") 237 + current_app.logger.debug(exception) 238 + return None 239 + 240 + 241 + def _set_into_session(session: SessionMixin, key: str, value: NamedTuple): 242 + session[key] = value._asdict() 243 + 244 + 245 + def _delete_from_session(session: SessionMixin, key: str): 246 + del session[key]
+1 -23
src/schema.sql
··· 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 ( 14 - did text not null primary key, 15 - handle text, 16 - pds_url text not null, 17 - authserver_iss text not null, 18 - access_token text, 19 - refresh_token text, 20 - dpop_authserver_nonce text not null, 21 - dpop_pds_nonce text, 22 - dpop_private_jwk text not null 23 - ) strict, without rowid; 1 + -- empty for now