A decentralized music tracking and discovery platform built on AT Protocol ๐ŸŽต
listenbrainz spotify atproto lastfm musicbrainz scrobbling

feat: Add support for login with password #13

merged opened by tsiry-sandratraina.com targeting main from feat/login-with-password
Labels

None yet.

Participants 1
AT URI
at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/sh.tangled.repo.pull/3mb7nv3ysai22
+226 -6
Diff #0
+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
··· 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
··· 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
··· 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
··· 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

sign up or login to add to the discussion
tsiry-sandratraina.com submitted #0
4 commits
expand
Add password login by resolving PDS from DID
Persist ATProto sessions and return JWT on login
Add password login and upsert sessions
Cache PDS URL for DID lookups in Redis
pull request successfully merged