+5
-1
Makefile
+5
-1
Makefile
-1
app.py
-1
app.py
···
1
-
from src.main import app
+1
pyproject.toml
+1
pyproject.toml
+24
-12
src/atproto/atproto_oauth.py
src/atproto/oauth.py
+24
-12
src/atproto/atproto_oauth.py
src/atproto/oauth.py
···
1
1
import sqlite3
2
-
from typing import Any
2
+
from typing import Any, NamedTuple
3
3
import time
4
4
import json
5
5
from authlib.jose import JsonWebKey, Key
···
13
13
from ..types import OAuthAuthRequest, OAuthSession
14
14
15
15
from ..security import is_safe_url, hardened_http
16
+
17
+
18
+
class OAuthTokens(NamedTuple):
19
+
access_token: str
20
+
refresh_token: str
21
+
scope: str
22
+
sub: str
16
23
17
24
18
25
# Prepares and sends a pushed auth request (PAR) via HTTP POST to the Authorization Server.
···
93
100
94
101
95
102
# Completes the auth flow by sending an initial auth token request.
96
-
# Returns token response (dict) and DPoP nonce (str)
103
+
# Returns token response (OAuthTokens) and DPoP nonce (str)
104
+
# IMPORTANT: the 'tokens.sub' field must be verified against the original request by code calling this function.
97
105
def initial_token_request(
98
106
auth_request: OAuthAuthRequest,
99
107
code: str,
100
108
app_url: str,
101
109
client_secret_jwk: Key,
102
-
) -> tuple[dict[str, str], str]:
110
+
) -> tuple[OAuthTokens, str]:
103
111
authserver_url = auth_request.authserver_iss
104
112
105
113
# Re-fetch server metadata
···
150
158
with hardened_http.get_session() as sess:
151
159
resp = sess.post(token_url, data=params, headers={"DPoP": dpop_proof})
152
160
153
-
token_body = resp.json()
154
-
print(token_body)
155
-
156
161
resp.raise_for_status()
157
-
158
-
# IMPORTANT: the 'sub' field must be verified against the original request by code calling this function.
162
+
token_body = resp.json()
163
+
try:
164
+
tokens = OAuthTokens(**token_body)
165
+
except TypeError:
166
+
raise Exception("invalid token body")
159
167
160
-
return token_body, dpop_authserver_nonce
168
+
return tokens, dpop_authserver_nonce
161
169
162
170
163
-
# Returns token response (dict) and DPoP nonce (str)
171
+
# Returns token response (OAuthTokens) and DPoP nonce (str)
164
172
def refresh_token_request(
165
173
user: OAuthSession,
166
174
app_url: str,
167
175
client_secret_jwk: Key,
168
-
) -> tuple[dict[str, str], str]:
176
+
) -> tuple[OAuthTokens, str]:
169
177
authserver_url = user.authserver_iss
170
178
171
179
# Re-fetch server metadata
···
218
226
219
227
resp.raise_for_status()
220
228
token_body = resp.json()
229
+
try:
230
+
tokens = OAuthTokens(**token_body)
231
+
except TypeError:
232
+
raise Exception("invalid token body")
221
233
222
-
return token_body, dpop_authserver_nonce
234
+
return tokens, dpop_authserver_nonce
223
235
224
236
225
237
# 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.
+1
src/db.py
+1
src/db.py
+1
-1
src/main.py
+1
-1
src/main.py
···
3
3
import json
4
4
5
5
from .atproto import PdsUrl, get_record, resolve_did_from_handle, resolve_pds_from_did
6
-
from .atproto.atproto_oauth import pds_authed_req
6
+
from .atproto.oauth import pds_authed_req
7
7
from .db import close_db_connection, get_db, init_db
8
8
from .oauth import oauth
9
9
from .types import OAuthSession
+6
-6
src/oauth.py
+6
-6
src/oauth.py
···
4
4
5
5
import json
6
6
7
-
from .atproto.atproto_oauth import initial_token_request, send_par_auth_request
8
7
from .atproto import (
9
8
is_valid_did,
10
9
is_valid_handle,
···
13
12
fetch_authserver_meta,
14
13
resolve_identity,
15
14
)
15
+
from .atproto.oauth import initial_token_request, send_par_auth_request
16
16
from .security import is_safe_url
17
17
from .types import OAuthAuthRequest
18
18
from .db import get_db
···
151
151
if row.did:
152
152
# If we started with an account identifier, this is simple
153
153
did, handle, pds_url = row.did, row.handle, row.pds_url
154
-
assert tokens["sub"] == did
154
+
assert tokens.sub == did
155
155
else:
156
-
did = tokens["sub"]
156
+
did = tokens.sub
157
157
assert is_valid_did(did)
158
158
identity = resolve_identity(did)
159
159
if not identity:
···
165
165
authserver_url = resolve_authserver_from_pds(pds_url)
166
166
assert authserver_url == authserver_iss
167
167
168
-
assert row.scope == tokens["scope"]
168
+
assert row.scope == tokens.scope
169
169
170
170
current_app.logger.debug("storing user did and handle")
171
171
db = get_db(current_app)
···
177
177
handle,
178
178
pds_url,
179
179
authserver_iss,
180
-
tokens["access_token"],
181
-
tokens["refresh_token"],
180
+
tokens.access_token,
181
+
tokens.refresh_token,
182
182
dpop_authserver_nonce,
183
183
None,
184
184
auth_request.dpop_private_jwk,
+3
-3
src/templates/editor.html
+3
-3
src/templates/editor.html
···
2
2
<html>
3
3
<head>
4
4
<meta charset="utf-8" />
5
-
<title>edit your profile — atlinks</title>
5
+
<title>edit your profile — ligo.at</title>
6
6
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
7
7
<link rel="stylesheet" href="{{ url_for('static', filename='inter.css') }}" />
8
8
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
···
12
12
<body>
13
13
<div class="wrapper editor" x-data="{ links: {{ links }}, linksChanged: false }">
14
14
<header>
15
-
<h1>atlinks</h1>
15
+
<h1>ligo.at</h1>
16
16
<span class="tagline">edit your profile & links</span>
17
17
</header>
18
18
···
34
34
</label>
35
35
{% if profile_from_bluesky %}
36
36
<p>
37
-
<span class="caption">Profile was fetched from Bluesky. On save it will use an independent, atlinks only copy.</span>
37
+
<span class="caption">Profile was fetched from Bluesky. On save it will use an independent, ligo.at only copy.</span>
38
38
</p>
39
39
{% endif %}
40
40
<label>
+3
-3
src/templates/index.html
+3
-3
src/templates/index.html
···
2
2
<html>
3
3
<head>
4
4
<meta charset="utf-8" />
5
-
<title>atlinks</title>
5
+
<title>ligo.at</title>
6
6
<meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
<link rel="stylesheet" href="{{ url_for('static', filename='inter.css') }}" />
8
8
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
···
10
10
<body>
11
11
<div class="wrapper home">
12
12
<header>
13
-
<h1>atlinks</h1>
14
-
<span class="tagline">name pending</span>
13
+
<h1>ligo.at</h1>
14
+
<span class="tagline">(noun) connection</span>
15
15
</header>
16
16
<p>
17
17
Get your own links page for all your social profiles. Decentralized thanks to
+2
-2
src/templates/login.html
+2
-2
src/templates/login.html
···
2
2
<html>
3
3
<head>
4
4
<meta charset="utf-8" />
5
-
<title>login — atlinks</title>
5
+
<title>login — ligo.at</title>
6
6
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
7
7
<link rel="stylesheet" href="{{ url_for('static', filename='inter.css') }}" />
8
8
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
···
10
10
<body>
11
11
<div class="wrapper login">
12
12
<header>
13
-
<h1>atlinks</h1>
13
+
<h1>ligo.at</h1>
14
14
<span class="tagline">log in to your account</span>
15
15
</header>
16
16
<form action="{{ url_for('auth_login') }}" method="post">
+1
-1
src/templates/profile.html
+1
-1
src/templates/profile.html
+23
uv.lock
+23
uv.lock
···
196
196
]
197
197
198
198
[[package]]
199
+
name = "gunicorn"
200
+
version = "23.0.0"
201
+
source = { registry = "https://pypi.org/simple" }
202
+
dependencies = [
203
+
{ name = "packaging" },
204
+
]
205
+
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
206
+
wheels = [
207
+
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
208
+
]
209
+
210
+
[[package]]
199
211
name = "idna"
200
212
version = "3.10"
201
213
source = { registry = "https://pypi.org/simple" }
···
233
245
{ name = "authlib" },
234
246
{ name = "dnspython" },
235
247
{ name = "flask", extra = ["dotenv"] },
248
+
{ name = "gunicorn" },
236
249
{ name = "requests" },
237
250
{ name = "requests-hardened" },
238
251
]
···
242
255
{ name = "authlib", specifier = ">=1.3" },
243
256
{ name = "dnspython", specifier = ">=2.8.0" },
244
257
{ name = "flask", extras = ["dotenv"], specifier = ">=3.1.2" },
258
+
{ name = "gunicorn", specifier = ">=23.0.0" },
245
259
{ name = "requests", specifier = ">=2.32" },
246
260
{ name = "requests-hardened", specifier = ">=1.2.0" },
247
261
]
···
296
310
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
297
311
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
298
312
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
313
+
]
314
+
315
+
[[package]]
316
+
name = "packaging"
317
+
version = "25.0"
318
+
source = { registry = "https://pypi.org/simple" }
319
+
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
320
+
wheels = [
321
+
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
299
322
]
300
323
301
324
[[package]]