A discord bot for teal.fm
discord tealfm music

oauth working woohoo

besaid.zone 9715f1cf 703bc015

verified
Changed files
+70 -19
apps
packages
database
+3 -3
apps/web/client.ts
··· 22 22 23 23 const metadata: OAuthClientMetadataInput = PUBLIC_URL 24 24 ? { 25 - client_name: "Teal.fm Discord Bot", 25 + client_name: "Disco Stu - Teal.fm Discord Bot", 26 26 client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, 27 27 jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`, 28 28 redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 29 - scope: "atproto transition:generic", 29 + scope: "atproto", 30 30 grant_types: ["authorization_code", "refresh_token"], 31 31 response_types: ["code"], 32 32 application_type: "web", ··· 37 37 : atprotoLoopbackClientMetadata( 38 38 `http://localhost?${new URLSearchParams([ 39 39 ["redirect_uri", "http://127.0.0.1:8002/oauth/callback"], 40 - ["scope", "atproto transition:generic"], 40 + ["scope", "atproto"], 41 41 ])}`, 42 42 ); 43 43
+27 -5
apps/web/index.ts
··· 1 - import { isValidHandle } from "@atproto/syntax"; 2 1 import { serve, type HttpBindings } from "@hono/node-server"; 3 2 import { COOKIE_SECRET } from "@tealfmbot/common/constants.js"; 4 3 import { logger } from "@tealfmbot/common/logger.js"; 5 4 import { Hono } from "hono"; 6 5 import { getSignedCookie } from "hono/cookie"; 6 + import { html } from "hono/html"; 7 7 import { validator } from "hono/validator"; 8 8 import pinoHttpLogger from "pino-http"; 9 9 10 10 import { client } from "./client"; 11 - import { createSession, MAX_AGE } from "./utils"; 11 + import { createSession, MAX_AGE, validateIdentifier } from "./utils"; 12 12 13 13 type Variables = { 14 14 logger: typeof logger; ··· 69 69 return c.redirect("/"); 70 70 }); 71 71 72 + app.get("/login", (c) => { 73 + return c.html( 74 + html` 75 + <form action="/login" method="post"> 76 + <label for="identifier">Identifier</label> 77 + <input id="identifier" name="identifier" type="text" placeholder="handle.bsky.social" required /> 78 + <button type="submit">Log in</button> 79 + </form> 80 + `, 81 + ); 82 + }); 83 + 72 84 app.post( 73 85 "/login", 74 86 validator("form", (value, c) => { 75 87 const identifier = value["identifier"]; 76 - if (!identifier || typeof identifier !== "string" || !isValidHandle(identifier)) { 88 + if (typeof identifier !== "string" || !validateIdentifier(identifier)) { 77 89 return c.json({ message: "Invalid handle, did or PDS URL" }, 400); 78 90 } 79 91 ··· 81 93 identifier, 82 94 }; 83 95 }), 84 - (c) => { 96 + async (c) => { 85 97 c.header("Cache-Control", "no-store"); 86 98 const { identifier } = c.req.valid("form"); 99 + const ac = new AbortController(); 87 100 try { 88 - return c.text(identifier); 101 + // if (identifier.startsWith("did:web")) { 102 + // const id = identifier.split(":")[2] 103 + // const url = await client.authorize(identifier, { 104 + // signal: ac.signal 105 + // }) 106 + // } 107 + const url = await client.authorize(identifier, { 108 + signal: ac.signal, 109 + }); 110 + return c.redirect(url.href); 89 111 } catch (error) { 90 112 logger.error({ error }, "oauth authorize failed"); 91 113 return c.json({ message: "oauth authorize failed" }, 500);
+1
apps/web/package.json
··· 10 10 }, 11 11 "dependencies": { 12 12 "@atproto/api": "^0.18.8", 13 + "@atproto/did": "^0.2.3", 13 14 "@atproto/oauth-client-node": "^0.3.13", 14 15 "@atproto/syntax": "^0.4.2", 15 16 "@hono/node-server": "^1.19.7",
+6 -6
apps/web/storage.ts
··· 15 15 .where("key", "=", key) 16 16 .executeTakeFirst(); 17 17 if (!result) return; 18 - return JSON.parse(result.state) as NodeSavedState; 18 + return JSON.parse(result.state); 19 19 } 20 20 async set(key: string, val: NodeSavedState) { 21 21 const state = JSON.stringify(val); 22 22 await this.db 23 23 .insertInto("auth_state") 24 24 .values({ key, state }) 25 - .onConflict((oc) => oc.doUpdateSet({ state })) 25 + .onConflict((oc) => oc.column("key").doUpdateSet({ state })) 26 26 .execute(); 27 27 } 28 28 async del(key: string) { ··· 39 39 .where("key", "=", key) 40 40 .executeTakeFirst(); 41 41 if (!result) return; 42 - return JSON.parse(result.session) as NodeSavedSession; 42 + return JSON.parse(result.session); 43 43 } 44 - async set(key: string, value: NodeSavedSession) { 45 - const session = JSON.stringify(value); 44 + async set(key: string, val: NodeSavedSession) { 45 + const session = JSON.stringify(val); 46 46 await this.db 47 47 .insertInto("auth_session") 48 48 .values({ key, session }) 49 - .onConflict((oc) => oc.doUpdateSet({ session })) 49 + .onConflict((oc) => oc.column("key").doUpdateSet({ session })) 50 50 .execute(); 51 51 } 52 52 async del(key: string) {
+1 -1
apps/web/tsconfig.json
··· 5 5 "target": "ESNext", 6 6 "module": "ESNext", 7 7 "moduleResolution": "bundler", 8 - "lib": ["ESNext"] 8 + "lib": ["ESNext"], 9 9 }, 10 10 "exclude": ["node_modules"] 11 11 }
+27 -2
apps/web/utils.ts
··· 1 1 import type { Context } from "hono"; 2 2 3 3 import { Agent } from "@atproto/api"; 4 + import { isAtprotoDid, isAtprotoDidWeb } from "@atproto/did"; 5 + import { isValidHandle } from "@atproto/syntax"; 4 6 import { COOKIE_SECRET } from "@tealfmbot/common/constants.ts"; 5 7 import { logger } from "@tealfmbot/common/logger.js"; 6 8 import { deleteCookie, generateSignedCookie, getSignedCookie } from "hono/cookie"; ··· 15 17 secure: process.env.NODE_ENV === "development" ? false : true, 16 18 httpOnly: true, 17 19 sameSite: "lax", 20 + maxAge: 60 * 60 * 24 * 7, 18 21 }); 19 22 20 23 return cookie; ··· 29 32 try { 30 33 const oauthSession = await client.restore(session); 31 34 return oauthSession ? new Agent(oauthSession) : null; 32 - } catch { 33 - logger.warn("oauth restore failed"); 35 + } catch (error) { 36 + logger.warn({ error }, "oauth restore failed"); 34 37 deleteCookie(c, "__teal_fm_bot_session"); 35 38 return null; 36 39 } 37 40 } 41 + 42 + export function isValidUrl(url: string) { 43 + if (URL.canParse(url)) { 44 + const pdsUrl = new URL(url); 45 + return pdsUrl.protocol === "http:" || pdsUrl.protocol === "https:"; 46 + } 47 + return false; 48 + } 49 + 50 + // https://github.com/bluesky-social/social-app/blob/main/src/lib/strings/handles.ts#L51 51 + export function validateIdentifier(input: string) { 52 + if (typeof input !== "string") return false; 53 + const results = { 54 + validHandle: isValidHandle(input), 55 + nonEmptyIdentifier: !input.trim(), 56 + validDid: isAtprotoDid(input), 57 + validUrl: isValidUrl(input), 58 + validDidWeb: isAtprotoDidWeb(input), 59 + }; 60 + 61 + return Object.values(results).includes(true); 62 + }
+2 -2
packages/database/migrations/1766974106212_oauth.ts
··· 3 3 export async function up(db: Kysely<any>): Promise<void> { 4 4 await db.schema 5 5 .createTable("auth_session") 6 - .addColumn("key", "varchar", (col) => col.primaryKey()) 6 + .addColumn("key", "varchar", (col) => col.primaryKey().unique()) 7 7 .addColumn("session", "varchar", (col) => col.notNull()) 8 8 .execute(); 9 9 await db.schema 10 10 .createTable("auth_state") 11 - .addColumn("key", "varchar", (col) => col.primaryKey()) 11 + .addColumn("key", "varchar", (col) => col.primaryKey().unique()) 12 12 .addColumn("state", "varchar", (col) => col.notNull()) 13 13 .execute(); 14 14 }
+3
pnpm-lock.yaml
··· 70 70 '@atproto/api': 71 71 specifier: ^0.18.8 72 72 version: 0.18.8 73 + '@atproto/did': 74 + specifier: ^0.2.3 75 + version: 0.2.3 73 76 '@atproto/oauth-client-node': 74 77 specifier: ^0.3.13 75 78 version: 0.3.13