+33
-30
src/atproto/__init__.py
+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
+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
+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
+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
+2
-2
src/security.py
+1
-1
src/templates/login.html
+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.