+50
-1
apps/api/src/bsky/app.ts
+50
-1
apps/api/src/bsky/app.ts
···
1
import type { BlobRef } from "@atproto/lexicon";
2
import { isValidHandle } from "@atproto/syntax";
3
import { ctx } from "context";
···
8
import { deepSnakeCaseKeys } from "lib";
9
import { createAgent } from "lib/agent";
10
import { env } from "lib/env";
11
import { requestCounter } from "metrics";
12
import dropboxAccounts from "schema/dropbox-accounts";
13
import googleDriveAccounts from "schema/google-drive-accounts";
···
40
41
app.post("/login", async (c) => {
42
requestCounter.add(1, { method: "POST", route: "/login" });
43
-
const { handle, cli } = await c.req.json();
44
if (typeof handle !== "string" || !isValidHandle(handle)) {
45
c.status(400);
46
return c.text("Invalid handle");
47
}
48
49
try {
50
const url = await ctx.oauthClient.authorize(handle, {
51
scope: "atproto transition:generic",
52
});
···
1
+
import { AtpAgent } from "@atproto/api";
2
import type { BlobRef } from "@atproto/lexicon";
3
import { isValidHandle } from "@atproto/syntax";
4
import { ctx } from "context";
···
9
import { deepSnakeCaseKeys } from "lib";
10
import { createAgent } from "lib/agent";
11
import { env } from "lib/env";
12
+
import extractPdsFromDid from "lib/extractPdsFromDid";
13
import { requestCounter } from "metrics";
14
import dropboxAccounts from "schema/dropbox-accounts";
15
import googleDriveAccounts from "schema/google-drive-accounts";
···
42
43
app.post("/login", async (c) => {
44
requestCounter.add(1, { method: "POST", route: "/login" });
45
+
const { handle, cli, password } = await c.req.json();
46
if (typeof handle !== "string" || !isValidHandle(handle)) {
47
c.status(400);
48
return c.text("Invalid handle");
49
}
50
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
+
99
const url = await ctx.oauthClient.authorize(handle, {
100
scope: "atproto transition:generic",
101
});
+3
-1
apps/api/src/context.ts
+3
-1
apps/api/src/context.ts
···
1
import { createClient } from "auth/client";
2
import axios from "axios";
3
-
import { createDb, migrateToLatest } from "db";
4
import drizzle from "drizzle";
5
import authVerifier from "lib/authVerifier";
6
import { env } from "lib/env";
···
44
headers: { Authorization: `Bearer ${env.MEILISEARCH_API_KEY}` },
45
}),
46
authVerifier,
47
};
48
49
export type Context = typeof ctx;
···
1
import { createClient } from "auth/client";
2
import axios from "axios";
3
+
import { createDb, Database, migrateToLatest } from "db";
4
import drizzle from "drizzle";
5
import authVerifier from "lib/authVerifier";
6
import { env } from "lib/env";
···
44
headers: { Authorization: `Bearer ${env.MEILISEARCH_API_KEY}` },
45
}),
46
authVerifier,
47
+
sqliteDb: db,
48
+
sqliteKv: kv,
49
};
50
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";
2
import type { NodeOAuthClient } from "@atproto/oauth-client-node";
3
4
export async function createAgent(
5
oauthClient: NodeOAuthClient,
6
did: string,
7
): Promise<Agent | null> {
8
-
let agent = null;
9
let retry = 0;
10
do {
11
try {
12
const oauthSession = await oauthClient.restore(did);
13
agent = oauthSession ? new Agent(oauthSession) : null;
14
if (agent === null) {
···
1
+
import { Agent, AtpAgent } from "@atproto/api";
2
import type { NodeOAuthClient } from "@atproto/oauth-client-node";
3
+
import extractPdsFromDid from "./extractPdsFromDid";
4
+
import { ctx } from "context";
5
6
export async function createAgent(
7
oauthClient: NodeOAuthClient,
8
did: string,
9
): Promise<Agent | null> {
10
+
let agent: Agent | null = null;
11
let retry = 0;
12
do {
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
+
}
43
const oauthSession = await oauthClient.restore(did);
44
agent = oauthSession ? new Agent(oauthSession) : null;
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
import Navbar from "./Navbar";
17
import Search from "./Search";
18
import SpotifyLogin from "./SpotifyLogin";
19
20
const Container = styled.div`
21
display: flex;
···
59
const { children } = props;
60
const withRightPane = props.withRightPane ?? true;
61
const [handle, setHandle] = useState("");
62
const jwt = localStorage.getItem("token");
63
const profile = useAtomValue(profileAtom);
64
const [token, setToken] = useState<string | null>(null);
65
const { did, cli } = useSearch({ strict: false });
66
67
useEffect(() => {
68
if (did && did !== "null") {
···
109
return;
110
}
111
112
if (API_URL.includes("localhost")) {
113
window.location.href = `${API_URL}/login?handle=${handle}`;
114
return;
···
151
{!jwt && (
152
<div className="mt-[40px]">
153
<div className="mb-[20px]">
154
-
<div className="mb-[15px]">
155
-
<LabelMedium className="!text-[var(--color-text)]">
156
Handle
157
</LabelMedium>
158
</div>
159
<Input
160
name="handle"
···
191
},
192
}}
193
/>
194
</div>
195
<Button
196
onClick={onLogin}
···
16
import Navbar from "./Navbar";
17
import Search from "./Search";
18
import SpotifyLogin from "./SpotifyLogin";
19
+
import { IconEye, IconEyeOff, IconLock } from "@tabler/icons-react";
20
21
const Container = styled.div`
22
display: flex;
···
60
const { children } = props;
61
const withRightPane = props.withRightPane ?? true;
62
const [handle, setHandle] = useState("");
63
+
const [password, setPassword] = useState("");
64
const jwt = localStorage.getItem("token");
65
const profile = useAtomValue(profileAtom);
66
const [token, setToken] = useState<string | null>(null);
67
const { did, cli } = useSearch({ strict: false });
68
+
const [passwordLogin, setPasswordLogin] = useState(false);
69
70
useEffect(() => {
71
if (did && did !== "null") {
···
112
return;
113
}
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
+
156
if (API_URL.includes("localhost")) {
157
window.location.href = `${API_URL}/login?handle=${handle}`;
158
return;
···
195
{!jwt && (
196
<div className="mt-[40px]">
197
<div className="mb-[20px]">
198
+
<div className="flex flex-row mb-[15px]">
199
+
<LabelMedium className="!text-[var(--color-text)] flex-1">
200
Handle
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>
208
</div>
209
<Input
210
name="handle"
···
241
},
242
}}
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
+
)}
299
</div>
300
<Button
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