+50
-1
apps/api/src/bsky/app.ts
+50
-1
apps/api/src/bsky/app.ts
···
1
+
import { AtpAgent } from "@atproto/api";
1
2
import type { BlobRef } from "@atproto/lexicon";
2
3
import { isValidHandle } from "@atproto/syntax";
3
4
import { ctx } from "context";
···
8
9
import { deepSnakeCaseKeys } from "lib";
9
10
import { createAgent } from "lib/agent";
10
11
import { env } from "lib/env";
12
+
import extractPdsFromDid from "lib/extractPdsFromDid";
11
13
import { requestCounter } from "metrics";
12
14
import dropboxAccounts from "schema/dropbox-accounts";
13
15
import googleDriveAccounts from "schema/google-drive-accounts";
···
40
42
41
43
app.post("/login", async (c) => {
42
44
requestCounter.add(1, { method: "POST", route: "/login" });
43
-
const { handle, cli } = await c.req.json();
45
+
const { handle, cli, password } = await c.req.json();
44
46
if (typeof handle !== "string" || !isValidHandle(handle)) {
45
47
c.status(400);
46
48
return c.text("Invalid handle");
47
49
}
48
50
49
51
try {
52
+
if (password) {
53
+
const defaultAgent = new AtpAgent({
54
+
service: new URL("https://bsky.social"),
55
+
});
56
+
const {
57
+
data: { did },
58
+
} = await defaultAgent.resolveHandle({ handle });
59
+
60
+
let pds = await ctx.redis.get(`pds:${did}`);
61
+
if (!pds) {
62
+
pds = await extractPdsFromDid(did);
63
+
await ctx.redis.setEx(`pds:${did}`, 60 * 15, pds);
64
+
}
65
+
66
+
const agent = new AtpAgent({
67
+
service: new URL(pds),
68
+
});
69
+
70
+
await agent.login({
71
+
identifier: handle,
72
+
password,
73
+
});
74
+
75
+
await ctx.sqliteDb
76
+
.insertInto("auth_session")
77
+
.values({
78
+
key: `atp:${did}`,
79
+
session: JSON.stringify(agent.session),
80
+
})
81
+
.onConflict((oc) =>
82
+
oc
83
+
.column("key")
84
+
.doUpdateSet({ session: JSON.stringify(agent.session) }),
85
+
)
86
+
.execute();
87
+
88
+
const token = jwt.sign(
89
+
{
90
+
did,
91
+
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
92
+
},
93
+
env.JWT_SECRET,
94
+
);
95
+
96
+
return c.text(`jwt:${token}`);
97
+
}
98
+
50
99
const url = await ctx.oauthClient.authorize(handle, {
51
100
scope: "atproto transition:generic",
52
101
});
+3
-1
apps/api/src/context.ts
+3
-1
apps/api/src/context.ts
···
1
1
import { createClient } from "auth/client";
2
2
import axios from "axios";
3
-
import { createDb, migrateToLatest } from "db";
3
+
import { createDb, Database, migrateToLatest } from "db";
4
4
import drizzle from "drizzle";
5
5
import authVerifier from "lib/authVerifier";
6
6
import { env } from "lib/env";
···
44
44
headers: { Authorization: `Bearer ${env.MEILISEARCH_API_KEY}` },
45
45
}),
46
46
authVerifier,
47
+
sqliteDb: db,
48
+
sqliteKv: kv,
47
49
};
48
50
49
51
export type Context = typeof ctx;
+33
-2
apps/api/src/lib/agent.ts
+33
-2
apps/api/src/lib/agent.ts
···
1
-
import { Agent } from "@atproto/api";
1
+
import { Agent, AtpAgent } from "@atproto/api";
2
2
import type { NodeOAuthClient } from "@atproto/oauth-client-node";
3
+
import extractPdsFromDid from "./extractPdsFromDid";
4
+
import { ctx } from "context";
3
5
4
6
export async function createAgent(
5
7
oauthClient: NodeOAuthClient,
6
8
did: string,
7
9
): Promise<Agent | null> {
8
-
let agent = null;
10
+
let agent: Agent | null = null;
9
11
let retry = 0;
10
12
do {
11
13
try {
14
+
const result = await ctx.sqliteDb
15
+
.selectFrom("auth_session")
16
+
.selectAll()
17
+
.where("key", "=", `atp:${did}`)
18
+
.executeTakeFirst();
19
+
if (result) {
20
+
let pds = await ctx.redis.get(`pds:${did}`);
21
+
if (!pds) {
22
+
pds = await extractPdsFromDid(did);
23
+
await ctx.redis.setEx(`pds:${did}`, 60 * 15, pds);
24
+
}
25
+
const atpAgent = new AtpAgent({
26
+
service: new URL(pds),
27
+
});
28
+
29
+
try {
30
+
await atpAgent.resumeSession(JSON.parse(result.session));
31
+
} catch (e) {
32
+
console.log("Error resuming session");
33
+
console.log(did);
34
+
console.log(e);
35
+
await ctx.sqliteDb
36
+
.deleteFrom("auth_session")
37
+
.where("key", "=", `atp:${did}`)
38
+
.execute();
39
+
}
40
+
41
+
return atpAgent;
42
+
}
12
43
const oauthSession = await oauthClient.restore(did);
13
44
agent = oauthSession ? new Agent(oauthSession) : null;
14
45
if (agent === null) {
+33
apps/api/src/lib/extractPdsFromDid.ts
+33
apps/api/src/lib/extractPdsFromDid.ts
···
1
+
export default async function extractPdsFromDid(
2
+
did: string,
3
+
): Promise<string | null> {
4
+
let didDocUrl: string;
5
+
6
+
if (did.startsWith("did:plc:")) {
7
+
didDocUrl = `https://plc.directory/${did}`;
8
+
} else if (did.startsWith("did:web:")) {
9
+
const domain = did.substring("did:web:".length);
10
+
didDocUrl = `https://${domain}/.well-known/did.json`;
11
+
} else {
12
+
throw new Error("Unsupported DID method");
13
+
}
14
+
15
+
const response = await fetch(didDocUrl);
16
+
if (!response.ok) throw new Error("Failed to fetch DID doc");
17
+
18
+
const doc: {
19
+
service?: Array<{
20
+
type: string;
21
+
id: string;
22
+
serviceEndpoint: string;
23
+
}>;
24
+
} = await response.json();
25
+
26
+
// Find the atproto PDS service
27
+
const pdsService = doc.service?.find(
28
+
(s: any) =>
29
+
s.type === "AtprotoPersonalDataServer" && s.id.endsWith("#atproto_pds"),
30
+
);
31
+
32
+
return pdsService?.serviceEndpoint ?? null;
33
+
}
+107
-2
apps/web/src/layouts/Main.tsx
+107
-2
apps/web/src/layouts/Main.tsx
···
16
16
import Navbar from "./Navbar";
17
17
import Search from "./Search";
18
18
import SpotifyLogin from "./SpotifyLogin";
19
+
import { IconEye, IconEyeOff, IconLock } from "@tabler/icons-react";
19
20
20
21
const Container = styled.div`
21
22
display: flex;
···
59
60
const { children } = props;
60
61
const withRightPane = props.withRightPane ?? true;
61
62
const [handle, setHandle] = useState("");
63
+
const [password, setPassword] = useState("");
62
64
const jwt = localStorage.getItem("token");
63
65
const profile = useAtomValue(profileAtom);
64
66
const [token, setToken] = useState<string | null>(null);
65
67
const { did, cli } = useSearch({ strict: false });
68
+
const [passwordLogin, setPasswordLogin] = useState(false);
66
69
67
70
useEffect(() => {
68
71
if (did && did !== "null") {
···
109
112
return;
110
113
}
111
114
115
+
if (passwordLogin) {
116
+
if (!password.trim()) {
117
+
return;
118
+
}
119
+
120
+
const response = await fetch(`${API_URL}/login`, {
121
+
method: "POST",
122
+
headers: {
123
+
"Content-Type": "application/json",
124
+
},
125
+
body: JSON.stringify({ handle, password }),
126
+
});
127
+
128
+
if (!response.ok) {
129
+
const error = await response.text();
130
+
alert(error);
131
+
return;
132
+
}
133
+
134
+
const data = await response.text();
135
+
const newToken = data.split("jwt:")[1];
136
+
localStorage.setItem("token", newToken);
137
+
setToken(data);
138
+
139
+
if (cli) {
140
+
await fetch("http://localhost:6996/token", {
141
+
method: "POST",
142
+
headers: {
143
+
"Content-Type": "application/json",
144
+
},
145
+
body: JSON.stringify({ token: newToken }),
146
+
});
147
+
}
148
+
149
+
if (!jwt && newToken) {
150
+
window.location.href = "/";
151
+
}
152
+
153
+
return;
154
+
}
155
+
112
156
if (API_URL.includes("localhost")) {
113
157
window.location.href = `${API_URL}/login?handle=${handle}`;
114
158
return;
···
151
195
{!jwt && (
152
196
<div className="mt-[40px]">
153
197
<div className="mb-[20px]">
154
-
<div className="mb-[15px]">
155
-
<LabelMedium className="!text-[var(--color-text)]">
198
+
<div className="flex flex-row mb-[15px]">
199
+
<LabelMedium className="!text-[var(--color-text)] flex-1">
156
200
Handle
157
201
</LabelMedium>
202
+
<LabelMedium
203
+
className="!text-[var(--color-primary)] cursor-pointer"
204
+
onClick={() => setPasswordLogin(!passwordLogin)}
205
+
>
206
+
{passwordLogin ? "OAuth Login" : "Password Login"}
207
+
</LabelMedium>
158
208
</div>
159
209
<Input
160
210
name="handle"
···
191
241
},
192
242
}}
193
243
/>
244
+
{passwordLogin && (
245
+
<Input
246
+
name="password"
247
+
startEnhancer={
248
+
<div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]">
249
+
<IconLock size={19} className="mt-[8px]" />
250
+
</div>
251
+
}
252
+
type="password"
253
+
placeholder="Password"
254
+
value={password}
255
+
onChange={(e) => setPassword(e.target.value)}
256
+
overrides={{
257
+
Root: {
258
+
style: {
259
+
backgroundColor: "var(--color-input-background)",
260
+
borderColor: "var(--color-input-background)",
261
+
marginTop: "1rem",
262
+
},
263
+
},
264
+
StartEnhancer: {
265
+
style: {
266
+
backgroundColor: "var(--color-input-background)",
267
+
},
268
+
},
269
+
InputContainer: {
270
+
style: {
271
+
backgroundColor: "var(--color-input-background)",
272
+
},
273
+
},
274
+
Input: {
275
+
style: {
276
+
color: "var(--color-text)",
277
+
caretColor: "var(--color-text)",
278
+
},
279
+
},
280
+
MaskToggleHideIcon: {
281
+
component: () => (
282
+
<IconEyeOff
283
+
className="text-[var(--color-text-muted)]"
284
+
size={20}
285
+
/>
286
+
),
287
+
},
288
+
MaskToggleShowIcon: {
289
+
component: () => (
290
+
<IconEye
291
+
className="text-[var(--color-text-muted)]"
292
+
size={20}
293
+
/>
294
+
),
295
+
},
296
+
}}
297
+
/>
298
+
)}
194
299
</div>
195
300
<Button
196
301
onClick={onLogin}
Submissions
4 commits
expand
collapse
Add password login by resolving PDS from DID
Persist ATProto sessions and return JWT on login
Store agent sessions into sqlite auth_session (key atp:{did}) when a
user logs in and return a signed JWT to the client. Expose sqliteDb in
context. createAgent now attempts to resume stored sessions with
AtpAgent and removes invalid sessions on resume failure.
Add password login and upsert sessions
Cache PDS URL for DID lookups in Redis
Store resolved PDS under key pds:<did> with 15m TTL to avoid repeated
resolution
pull request successfully merged