tangled
alpha
login
or
join now
ligo.at
/
core
6
fork
atom
decentralized and customizable links page on top of atproto
ligo.at
atproto
link-in-bio
python
uv
6
fork
atom
overview
issues
2
pulls
pipelines
refresh oauth tokens when expired
nauta.one
1 month ago
e0c9fd09
b143f96c
+70
-14
5 changed files
expand all
collapse all
unified
split
src
atproto
oauth.py
types.py
auth.py
main.py
oauth.py
+4
-3
src/atproto/oauth.py
···
168
168
async def refresh_token_request(
169
169
client: ClientSession,
170
170
user: OAuthSession,
171
171
-
app_url: str,
171
171
+
app_host: str,
172
172
client_secret_jwk: Key,
173
173
) -> tuple[OAuthTokens, str]:
174
174
authserver_url = user.authserver_iss
···
179
179
raise Exception("missing authserver meta")
180
180
181
181
# Construct token request fields
182
182
-
client_id = f"{app_url}oauth/metadata"
182
182
+
client_id = f"https://{app_host}/oauth/metadata"
183
183
184
184
# Self-signed JWT using the private key declared in client metadata JWKS (confidential client)
185
185
client_assertion = _client_assertion_jwt(
···
255
255
)
256
256
257
257
async with hardened_http.get_session() as session:
258
258
-
response = await session.post(
258
258
+
response = await session.request(
259
259
+
method,
259
260
url,
260
261
headers={
261
262
"Authorization": f"DPoP {access_token}",
+11
src/atproto/types.py
···
1
1
+
from datetime import datetime, timezone
1
2
from typing import NamedTuple, NewType
2
3
3
4
AuthserverUrl = NewType("AuthserverUrl", str)
···
25
26
authserver_iss: str
26
27
access_token: str | None
27
28
refresh_token: str | None
29
29
+
expires_at: int | None
28
30
dpop_authserver_nonce: str
29
31
dpop_pds_nonce: str | None
30
32
dpop_private_jwk: str
33
33
+
34
34
+
def is_expired(self, now: datetime | None = None) -> bool:
35
35
+
if self.expires_at is None:
36
36
+
return True
37
37
+
38
38
+
if now is None:
39
39
+
now = datetime.now(timezone.utc)
40
40
+
41
41
+
return self.expires_at < int(now.timestamp())
+34
-2
src/auth.py
···
1
1
+
from datetime import datetime, timedelta, timezone
1
2
from typing import NamedTuple, TypeVar
2
3
3
3
-
from flask import current_app
4
4
+
from aiohttp.client import ClientSession
5
5
+
from authlib.jose import JsonWebKey
6
6
+
from flask import current_app, request
4
7
from flask.sessions import SessionMixin
5
8
9
9
+
from src.atproto.oauth import refresh_token_request
6
10
from src.atproto.types import OAuthAuthRequest, OAuthSession
7
11
8
12
···
35
39
36
40
37
41
def _delete_from_session(session: SessionMixin, key: str):
38
38
-
del session[key]
42
42
+
try:
43
43
+
del session[key]
44
44
+
except KeyError:
45
45
+
pass
46
46
+
47
47
+
48
48
+
async def refresh_auth_session(
49
49
+
session: SessionMixin,
50
50
+
client: ClientSession,
51
51
+
current: OAuthSession,
52
52
+
) -> OAuthSession | None:
53
53
+
current_app.logger.debug("refreshing oauth tokens")
54
54
+
CLIENT_SECRET_JWK = JsonWebKey.import_key(current_app.config["CLIENT_SECRET_JWK"])
55
55
+
tokens, dpop_authserver_nonce = await refresh_token_request(
56
56
+
client=client,
57
57
+
user=current,
58
58
+
app_host=request.host,
59
59
+
client_secret_jwk=CLIENT_SECRET_JWK,
60
60
+
)
61
61
+
now = datetime.now(timezone.utc)
62
62
+
expires_at = now + timedelta(seconds=tokens.expires_in or 300)
63
63
+
user = current._replace(
64
64
+
access_token=tokens.access_token,
65
65
+
refresh_token=tokens.refresh_token,
66
66
+
expires_at=int(expires_at.timestamp()),
67
67
+
dpop_pds_nonce=dpop_authserver_nonce,
68
68
+
)
69
69
+
save_auth_session(session, user)
70
70
+
return user
39
71
40
72
41
73
OAuthClass = TypeVar("OAuthClass")
+17
-9
src/main.py
···
1
1
import asyncio
2
2
import json
3
3
-
from typing import Any, NamedTuple
3
3
+
from typing import Any, NamedTuple, cast
4
4
5
5
from aiohttp.client import ClientSession
6
6
from flask import Flask, g, redirect, render_template, request, session, url_for
···
15
15
)
16
16
from src.atproto.oauth import pds_authed_req
17
17
from src.atproto.types import DID, Handle, OAuthSession, PdsUrl
18
18
-
from src.auth import get_auth_session, save_auth_session
18
18
+
from src.auth import (
19
19
+
get_auth_session,
20
20
+
refresh_auth_session,
21
21
+
save_auth_session,
22
22
+
)
19
23
from src.db import KV, close_db_connection, get_db, init_db
20
24
from src.oauth import oauth
21
25
···
32
36
g.user = get_auth_session(session)
33
37
34
38
35
35
-
def get_user() -> OAuthSession | None:
36
36
-
return g.user
39
39
+
async def get_user() -> OAuthSession | None:
40
40
+
user = cast(OAuthSession | None, g.user)
41
41
+
if user is not None and user.is_expired():
42
42
+
async with ClientSession() as client:
43
43
+
user = await refresh_auth_session(session, client, user)
44
44
+
return user
37
45
38
46
39
47
@app.teardown_appcontext
···
114
122
115
123
116
124
@app.get("/login")
117
117
-
def page_login():
118
118
-
if get_user() is not None:
125
125
+
async def page_login():
126
126
+
if await get_user() is not None:
119
127
return redirect("/editor")
120
128
return render_template("login.html", auth_servers=auth_servers)
121
129
···
138
146
139
147
@app.get("/editor")
140
148
async def page_editor():
141
141
-
user = get_user()
149
149
+
user = await get_user()
142
150
if user is None:
143
151
return redirect("/login", 302)
144
152
···
167
175
168
176
@app.post("/editor/profile")
169
177
async def post_editor_profile():
170
170
-
user = get_user()
178
178
+
user = await get_user()
171
179
if user is None:
172
180
url = url_for("auth_logout")
173
181
return htmx_response(redirect=url) if htmx else redirect(url, 303)
···
211
219
212
220
@app.post("/editor/links")
213
221
async def post_editor_links():
214
214
-
user = get_user()
222
222
+
user = await get_user()
215
223
if user is None:
216
224
url = url_for("auth_logout")
217
225
return htmx_response(redirect=url) if htmx else redirect(url, 303)
+4
src/oauth.py
···
1
1
import json
2
2
+
from datetime import datetime, timedelta, timezone
2
3
from urllib.parse import urlencode
3
4
4
5
from aiohttp.client import ClientSession
···
200
201
assert pds_url is not None
201
202
202
203
current_app.logger.debug("storing user oauth session")
204
204
+
now = datetime.now(timezone.utc)
205
205
+
expires_at = now + timedelta(seconds=tokens.expires_in or 300)
203
206
oauth_session = OAuthSession(
204
207
did,
205
208
handle,
···
207
210
authserver_iss,
208
211
tokens.access_token,
209
212
tokens.refresh_token,
213
213
+
int(expires_at.timestamp()),
210
214
dpop_authserver_nonce,
211
215
None,
212
216
auth_request.dpop_private_jwk,