馃馃攽 PAM provider for ATProto
1import { AtpAgent } from "https://esm.sh/@atproto/api@0.14?target=deno";
2
3const SOCK = "/run/atpam.sock";
4const PID = "/run/atpam.pid";
5const DIDMAP = "/etc/atpam/dids.json";
6const AT_ENDPOINT = "https://zio.blue"; // NOTE: hardcoded because lazy rn
7
8function exists(path: string): boolean {
9 try { Deno.statSync(path); return true; }
10 catch (e: any) {
11 if (e instanceof Deno.errors.NotFound) return false;
12 throw e;
13 }
14}
15const loadMap = () => exists(DIDMAP) ? JSON.parse(Deno.readTextFileSync(DIDMAP)) : {};
16const saveMap = (m: Record<string,string>) =>
17 Deno.writeTextFileSync(DIDMAP, JSON.stringify(m, null, 2));
18
19const didToUnix = (did: string) =>
20 did.replace(/^did:/, "").replace(/:/g, "-").replace(/\./g, "-");
21
22async function resolveCurrentHandle(did: string): Promise<string> {
23 const [meth, id] = did.replace(/^did:/, "").split(":");
24 if (meth === "plc") {
25 const doc = await fetch(`https://plc.directory/${did}`).then(r => r.json());
26 const aka = doc.alsoKnownAs?.find((s: string) => s.startsWith("at://"));
27 return aka ? aka.slice(5) : "handle.invalid";
28 } else if (meth === "web") {
29 const doc = await fetch(`https://${id}/.well-known/did.json`).then(r => r.json());
30 const aka = doc.alsoKnownAs?.find((s: string) => s.startsWith("at://"));
31 return aka ? aka.slice(5) : "handle.invalid";
32 }
33 return "handle.invalid";
34}
35
36async function authenticate(
37 cred: {handle?:string; did?:string; unix?:string; password:string}
38): Promise<{ok:boolean; unixUser?:string; error?:string}> {
39 const map = loadMap();
40 let did: string;
41
42 if (cred.did) {
43 did = cred.did;
44 } else if (cred.unix) {
45 const expand = (u: string): string | null => {
46 if (u.startsWith("didplc-")) return "did:plc:" + u.slice(7);
47 if (u.startsWith("didweb-")) return "did:web:" + u.slice(7).replace(/-/g, ".");
48 return null;
49 };
50 const exp = expand(cred.unix);
51 if (!exp) return {ok:false, error:"Bad unix name"};
52 did = exp;
53 } else if (cred.handle) {
54 let cached = map[cred.handle];
55 if (!cached) {
56 const u = new URL(`${AT_ENDPOINT}/xrpc/com.atproto.identity.resolveHandle`);
57 u.searchParams.set("handle", cred.handle);
58 const {did: resolved} = await fetch(u).then(r => r.json());
59 if (!resolved) return {ok:false, error:"Handle not found"};
60 cached = resolved;
61 map[cred.handle] = cached;
62 saveMap(map);
63 }
64 did = cached;
65 } else {
66 return {ok:false, error:"No identity provided"};
67 }
68
69 const current = await resolveCurrentHandle(did);
70 if (current === "handle.invalid")
71 return {ok:false, error:"DID has no valid handle"};
72
73 const agent = new AtpAgent({service: AT_ENDPOINT});
74 //try {
75 await agent.login({identifier:current, password:cred.password});
76 //} catch {
77 // return {ok:false, error:"Auth failed"};
78 //}
79 return {ok:true, unixUser: didToUnix(did)};
80}
81
82/* ---------- socket server ---------- */
83try { await Deno.remove(SOCK); } catch {}
84const listener = Deno.listen({ path: SOCK, transport: "unix" });
85Deno.writeTextFileSync(PID, String(Deno.pid));
86console.log(`atpam listening on ${SOCK} (PID ${Deno.pid})`);
87
88for await (const conn of listener) {
89 (async () => {
90 const buf = new Uint8Array(4096);
91 const n = await conn.read(buf);
92 if (!n) return conn.close();
93 //try {
94 const cred = JSON.parse(new TextDecoder().decode(buf.slice(0, n)));
95 const res = await authenticate(cred);
96 await conn.write(new TextEncoder().encode(JSON.stringify(res)));
97 //} catch {
98 // await conn.write(new TextEncoder().encode(JSON.stringify({ok:false})));
99 //}
100 conn.close();
101 })();
102}