decentralized and customizable links page on top of atproto

support oauth starting with auth server

Changed files
+65 -32
src
+10 -2
src/atproto2/__init__.py
··· 34 34 return (did, handle, doc) 35 35 36 36 if is_valid_did(query): 37 - # TODO: resolve did identity 38 - return None 37 + did = query 38 + doc = resolve_doc_from_did(did) 39 + if not doc: 40 + return None 41 + handle = handle_from_doc(doc) 42 + if not handle: 43 + return None 44 + if resolve_did_from_handle(handle) != did: 45 + return None 46 + return (did, handle, doc) 39 47 40 48 return None 41 49
+1 -2
src/atproto2/atproto_oauth.py
··· 120 120 def send_par_auth_request( 121 121 authserver_url: str, 122 122 authserver_meta: dict[str, str], 123 - login_hint: str, 123 + login_hint: str | None, 124 124 client_id: str, 125 125 redirect_uri: str, 126 126 scope: str, ··· 159 159 } 160 160 if login_hint: 161 161 par_body["login_hint"] = login_hint 162 - # print(par_body) 163 162 164 163 # IMPORTANT: Pushed Authorization Request URL is untrusted input, SSRF mitigations are needed 165 164 assert is_safe_url(par_url)
+3 -1
src/main.py
··· 76 76 77 77 @app.post("/login") 78 78 def auth_login(): 79 - username = request.form.get("username") 79 + username = request.form.get("username", "") 80 + if username[0] == "@": 81 + username = username[1:] 80 82 if not username: 81 83 return redirect(url_for("page_login"), 303) 82 84 return redirect(url_for("oauth.oauth_start", username=username), 303)
+51 -27
src/oauth.py
··· 4 4 5 5 import json 6 6 7 + from .atproto2.atproto_identity import is_valid_did, is_valid_handle 7 8 from .atproto2.atproto_oauth import initial_token_request, send_par_auth_request 8 9 from .atproto2.atproto_security import is_safe_url 9 10 from .atproto2 import ( ··· 26 27 username = request.args.get("username") 27 28 if not username: 28 29 return "missing ?username", 400 29 - login_hint = username 30 - identity = resolve_identity(username) 31 - if identity is None: 32 - return "couldnt resolve identity", 500 33 - did, handle, doc = identity 34 - pds_url = pds_endpoint_from_doc(doc) 35 - if not pds_url: 36 - return "pds not found", 404 37 - current_app.logger.debug(f"account PDS: {pds_url}") 38 - authserver_url = resolve_authserver_from_pds(pds_url) 39 - if not authserver_url: 40 - return "authserver not found", 404 30 + 31 + if is_valid_handle(username) or is_valid_did(username): 32 + login_hint = username 33 + identity = resolve_identity(username) 34 + if identity is None: 35 + return "couldnt resolve identity", 500 36 + did, handle, doc = identity 37 + pds_url = pds_endpoint_from_doc(doc) 38 + if not pds_url: 39 + return "pds not found", 404 40 + current_app.logger.debug(f"account PDS: {pds_url}") 41 + authserver_url = resolve_authserver_from_pds(pds_url) 42 + if not authserver_url: 43 + return "authserver not found", 404 44 + 45 + elif username.startswith("https://") and is_safe_url(username): 46 + did, handle, pds_url = None, None, None 47 + login_hint = None 48 + authserver_url = resolve_authserver_from_pds(username) or username 49 + 50 + else: 51 + return "not a valid handle, did or auth server", 400 52 + 41 53 current_app.logger.debug(f"Authserver: {authserver_url}") 42 - 43 54 assert is_safe_url(authserver_url) 44 55 authserver_meta = resolve_authserver_meta(authserver_url) 45 56 if not authserver_meta: ··· 49 60 dpop_private_jwk: Key = JsonWebKey.generate_key("EC", "P-256", is_private=True) 50 61 scope = "atproto transition:generic" 51 62 52 - app_url = request.url_root.replace("http://", "https://") 53 - redirect_uri = f"{app_url}oauth/callback" 54 - client_id = f"{app_url}oauth/metadata" 63 + host = request.host 64 + metadata_endpoint = url_for("oauth.oauth_metadata") 65 + client_id = f"https://{host}{metadata_endpoint}" 66 + callback_endpoint = url_for("oauth.oauth_callback") 67 + redirect_uri = f"https://{host}{callback_endpoint}" 68 + 69 + current_app.logger.debug(client_id) 70 + current_app.logger.debug(redirect_uri) 55 71 56 72 CLIENT_SECRET_JWK = JsonWebKey.import_key(current_app.config["CLIENT_SECRET_JWK"]) 57 73 ··· 66 82 dpop_private_jwk, 67 83 ) 68 84 if resp.status_code == 400: 69 - current_app.logger.info(f"PAR HTTP 400: {resp.json()}") 85 + current_app.logger.debug(f"PAR HTTP 400: {resp.json()}") 70 86 resp.raise_for_status() 71 87 72 - par_request_uri = resp.json()["request_uri"] 88 + par_request_uri: str = resp.json()["request_uri"] 73 89 current_app.logger.debug(f"saving oauth_auth_request to DB state={state}") 74 90 oauth_auth_requests[state] = { 75 91 "authserver_iss": authserver_meta["issuer"], 76 - "did": did, 77 - "handle": handle, 78 - "pds_url": pds_url, 92 + "did": did or "", # TODO: use actual typing 93 + "handle": handle or "", 94 + "pds_url": pds_url or "", 79 95 "pkce_verifier": pkce_verifier, 80 96 "scope": scope, 81 97 "dpop_authserver_nonce": dpop_authserver_nonce, 82 98 "dpop_private_jwk": dpop_private_jwk.as_json(is_private=True), 83 99 } 84 100 85 - auth_url = authserver_meta["authorization_endpoint"] 86 - assert is_safe_url(auth_url) 101 + auth_endpoint = authserver_meta["authorization_endpoint"] 102 + assert is_safe_url(auth_endpoint) 87 103 qparam = urlencode({"client_id": client_id, "request_uri": par_request_uri}) 88 - return redirect(f"{auth_url}?{qparam}") 104 + return redirect(f"{auth_endpoint}?{qparam}") 89 105 90 106 91 107 @oauth.get("/callback") ··· 121 137 did, handle, pds_url = row["did"], row["handle"], row["pds_url"] 122 138 assert tokens["sub"] == did 123 139 else: 124 - # we started with auth server URL 125 - raise Exception() 140 + did = tokens["sub"] 141 + assert is_valid_did(did) 142 + identity = resolve_identity(did) 143 + if not identity: 144 + return "could not resolve identity", 500 145 + did, handle, did_doc = identity 146 + pds_url = pds_endpoint_from_doc(did_doc) 147 + if not pds_url: 148 + return "could not resolve pds", 500 149 + authserver_url = resolve_authserver_from_pds(pds_url) 150 + assert authserver_url == authserver_iss 126 151 127 152 assert row["scope"] == tokens["scope"] 128 153 ··· 161 186 return jsonify( 162 187 { 163 188 "client_id": f"https://{host}{metadata_endpoint}", 164 - "application_type": "web", 165 189 "grant_types": ["authorization_code", "refresh_token"], 166 190 "scope": "atproto transition:generic", 167 191 "response_types": ["code"],