A discord bot for teal.fm
discord tealfm music

getting oauth going

besaid.zone 09091851 2804a5d0

verified
Changed files
+154 -37
apps
packages
common
database
+33 -35
apps/web/client.ts
··· 6 6 type OAuthClientMetadataInput, 7 7 } from "@atproto/oauth-client-node"; 8 8 import { PUBLIC_URL, PRIVATE_KEYS } from "@tealfmbot/common/constants.ts"; 9 - import type { Database } from "@tealfmbot/database/types.ts"; 9 + import { db } from "@tealfmbot/database/db.ts"; 10 10 import assert from "node:assert"; 11 11 12 12 import { SessionStore, StateStore } from "./storage"; 13 13 14 - export async function createOauthClient(db: Database) { 15 - const keyset = 16 - PUBLIC_URL && PRIVATE_KEYS 17 - ? new Keyset(await Promise.all(PRIVATE_KEYS.map((jwk) => JoseKey.fromJWK(jwk)))) 18 - : undefined; 14 + const keyset = 15 + PUBLIC_URL && PRIVATE_KEYS 16 + ? new Keyset(await Promise.all(PRIVATE_KEYS.map((jwk) => JoseKey.fromJWK(jwk)))) 17 + : undefined; 19 18 20 - assert(!PUBLIC_URL || keyset?.size, "PRIVATE_KEYS environment variable must be set"); 19 + assert(!PUBLIC_URL || keyset?.size, "PRIVATE_KEYS environment variable must be set"); 21 20 22 - const pk = keyset?.findPrivateKey({ usage: "sign" }); 21 + const pk = keyset?.findPrivateKey({ usage: "sign" }); 23 22 24 - const clientMetadata: OAuthClientMetadataInput = PUBLIC_URL 25 - ? { 26 - client_name: "Teal.fm Discord Bot", 27 - client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, 28 - jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`, 29 - redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 30 - scope: "atproto transition:generic", 31 - grant_types: ["authorization_code", "refresh_token"], 32 - response_types: ["code"], 33 - application_type: "web", 34 - token_endpoint_auth_method: pk ? "private_key_jwt" : "none", 35 - token_endpoint_auth_signing_alg: pk ? pk.alg : undefined, 36 - dpop_bound_access_tokens: true, 37 - } 38 - : atprotoLoopbackClientMetadata( 39 - `http://localhost?${new URLSearchParams([ 40 - ["redirect_uri", "http://127.0.0.1:8002/oauth/callback"], 41 - ["scope", "atproto transition:generic"], 42 - ])}`, 43 - ); 23 + const metadata: OAuthClientMetadataInput = PUBLIC_URL 24 + ? { 25 + client_name: "Teal.fm Discord Bot", 26 + client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, 27 + jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`, 28 + redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 29 + scope: "atproto transition:generic", 30 + grant_types: ["authorization_code", "refresh_token"], 31 + response_types: ["code"], 32 + application_type: "web", 33 + token_endpoint_auth_method: pk ? "private_key_jwt" : "none", 34 + token_endpoint_auth_signing_alg: pk ? pk.alg : undefined, 35 + dpop_bound_access_tokens: true, 36 + } 37 + : atprotoLoopbackClientMetadata( 38 + `http://localhost?${new URLSearchParams([ 39 + ["redirect_uri", "http://127.0.0.1:8002/oauth/callback"], 40 + ["scope", "atproto transition:generic"], 41 + ])}`, 42 + ); 44 43 45 - return new NodeOAuthClient({ 46 - ...(typeof keyset !== "undefined" ? { keyset } : undefined), 47 - clientMetadata, 48 - stateStore: new StateStore(db), 49 - sessionStore: new SessionStore(db), 50 - }); 51 - } 44 + export const client = new NodeOAuthClient({ 45 + ...(typeof keyset !== "undefined" ? { keyset } : undefined), 46 + clientMetadata: metadata, 47 + stateStore: new StateStore(db), 48 + sessionStore: new SessionStore(db), 49 + });
+54 -1
apps/web/index.ts
··· 3 3 import { Hono } from "hono"; 4 4 import pinoHttpLogger from "pino-http"; 5 5 6 + import { client } from "./client"; 7 + import { createSession, MAX_AGE } from "./utils"; 8 + import { getSignedCookie } from "hono/cookie"; 9 + import { COOKIE_SECRET } from "@tealfmbot/common/constants.js"; 10 + 6 11 type Variables = { 7 12 logger: typeof logger; 8 13 }; ··· 26 31 return c.text("yo!!"); 27 32 }); 28 33 29 - serve({ 34 + app.get("/oauth-client-metadata.json", (c) => { 35 + c.header("Cache-Control", `max-age=${MAX_AGE}, public`); 36 + return c.json(client.clientMetadata); 37 + }); 38 + 39 + app.get("/.well-known/jwks.json", (c) => { 40 + c.header("Cache-Control", `max-age=${MAX_AGE}, public`); 41 + return c.json(client.jwks); 42 + }); 43 + 44 + app.get("/oauth/callback", async (c) => { 45 + c.header("Cache-Control", "no-store") 46 + const params = new URLSearchParams(c.req.url.split("?")[1]) 47 + 48 + try { 49 + const session = await getSignedCookie(c, COOKIE_SECRET, "__teal_fm_bot_session") 50 + if (session) { 51 + try { 52 + const oauthSession = await client.restore(session) 53 + if (oauthSession) oauthSession.signOut() 54 + } catch { 55 + logger.warn("oauth restore failed") 56 + } 57 + } 58 + const oauth = await client.callback(params) 59 + await createSession(oauth.session.did); 60 + 61 + } catch { 62 + logger.error("oauth callback failed") 63 + } 64 + 65 + return c.redirect("/") 66 + }) 67 + 68 + const server = serve({ 30 69 fetch: app.fetch, 31 70 port: 8002, 32 71 }); 72 + 73 + process.on("SIGINT", () => { 74 + server.close(); 75 + process.exit(0); 76 + }); 77 + process.on("SIGTERM", () => { 78 + server.close((err) => { 79 + if (err) { 80 + console.error(err); 81 + process.exit(1); 82 + } 83 + process.exit(0); 84 + }); 85 + });
+2 -1
apps/web/package.json
··· 9 9 "typecheck": "tsc --noEmit" 10 10 }, 11 11 "dependencies": { 12 + "@atproto/api": "^0.18.8", 12 13 "@atproto/oauth-client-node": "^0.3.13", 13 14 "@hono/node-server": "^1.19.7", 14 15 "@tealfmbot/common": "workspace:*", 15 - "@tealfmbot/tsconfig": "workspace:*", 16 16 "@tealfmbot/database": "workspace:*", 17 + "@tealfmbot/tsconfig": "workspace:*", 17 18 "hono": "^4.11.3", 18 19 "pino-http": "^11.0.0" 19 20 },
+35
apps/web/utils.ts
··· 1 + import { COOKIE_SECRET } from "@tealfmbot/common/constants.ts"; 2 + import type { Context } from "hono"; 3 + import { deleteCookie, generateSignedCookie, getSignedCookie } from "hono/cookie"; 4 + import { client } from "./client"; 5 + import { Agent } from "@atproto/api"; 6 + import { logger } from "@tealfmbot/common/logger.js"; 7 + 8 + export const MAX_AGE = process.env.NODE_ENV === "production" ? 60 : 0; 9 + 10 + export async function createSession(value: string) { 11 + const cookie = await generateSignedCookie("__teal_fm_bot_session", value, COOKIE_SECRET, { 12 + path: "/", 13 + secure: process.env.NODE_ENV === "development" ? false : true, 14 + httpOnly: true, 15 + sameSite: "lax", 16 + }); 17 + 18 + return cookie; 19 + } 20 + 21 + export async function getSessionAgent(c: Context) { 22 + c.header("Vary", "Cookie") 23 + const session = await getSignedCookie(c, COOKIE_SECRET, "__teal_fm_bot_session") 24 + if (!session) return null; 25 + c.header("Cache-Control", `max-age=${MAX_AGE}, private`) 26 + 27 + try { 28 + const oauthSession = await client.restore(session) 29 + return oauthSession ? new Agent(oauthSession) : null 30 + } catch { 31 + logger.warn("oauth restore failed") 32 + deleteCookie(c, "__teal_fm_bot_session") 33 + return null; 34 + } 35 + }
+1
packages/common/constants.ts
··· 9 9 export const TAP_ADMIN_PASSWORD = process.env.TAP_ADMIN_PASSWORD as string; 10 10 export const DATABASE_URL = process.env.DATABASE_URL as string; 11 11 export const PUBLIC_URL = process.env.PUBLIC_URL as string; 12 + export const COOKIE_SECRET = process.env.COOKIE_SECRET as string; 12 13 export const PRIVATE_KEYS = process.env.PRIVATE_KEYS as unknown as string[];
+1
packages/database/types.ts
··· 1 + // @ts-nocheck 1 2 import type { Artists, Plays } from "kysely-codegen"; 2 3 3 4 import { db } from "./db.ts";
+28
pnpm-lock.yaml
··· 67 67 68 68 apps/web: 69 69 dependencies: 70 + '@atproto/api': 71 + specifier: ^0.18.8 72 + version: 0.18.8 70 73 '@atproto/oauth-client-node': 71 74 specifier: ^0.3.13 72 75 version: 0.3.13 ··· 187 190 188 191 '@atproto-labs/simple-store@0.3.0': 189 192 resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} 193 + 194 + '@atproto/api@0.18.8': 195 + resolution: {integrity: sha512-Qo3sGd1N5hdHTaEWUBgptvPkULt2SXnMcWRhveSyctSd/IQwTMyaIH6E62A1SU+8xBSN5QLpoUJNE7iSrYM2Zg==} 190 196 191 197 '@atproto/common-web@0.4.7': 192 198 resolution: {integrity: sha512-vjw2+81KPo2/SAbbARGn64Ln+6JTI0FTI4xk8if0ebBfDxFRmHb2oSN1y77hzNq/ybGHqA2mecfhS03pxC5+lg==} ··· 575 581 atomic-sleep@1.0.0: 576 582 resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} 577 583 engines: {node: '>=8.0.0'} 584 + 585 + await-lock@2.2.2: 586 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 578 587 579 588 balanced-match@1.0.2: 580 589 resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} ··· 1255 1264 resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==} 1256 1265 engines: {node: ^20.0.0 || >=22.0.0} 1257 1266 1267 + tlds@1.261.0: 1268 + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 1269 + hasBin: true 1270 + 1258 1271 to-regex-range@5.0.1: 1259 1272 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 1260 1273 engines: {node: '>=8.0'} ··· 1383 1396 1384 1397 '@atproto-labs/simple-store@0.3.0': {} 1385 1398 1399 + '@atproto/api@0.18.8': 1400 + dependencies: 1401 + '@atproto/common-web': 0.4.7 1402 + '@atproto/lexicon': 0.6.0 1403 + '@atproto/syntax': 0.4.2 1404 + '@atproto/xrpc': 0.7.7 1405 + await-lock: 2.2.2 1406 + multiformats: 9.9.0 1407 + tlds: 1.261.0 1408 + zod: 3.25.76 1409 + 1386 1410 '@atproto/common-web@0.4.7': 1387 1411 dependencies: 1388 1412 '@atproto/lex-data': 0.0.3 ··· 1742 1766 argparse@2.0.1: {} 1743 1767 1744 1768 atomic-sleep@1.0.0: {} 1769 + 1770 + await-lock@2.2.2: {} 1745 1771 1746 1772 balanced-match@1.0.2: {} 1747 1773 ··· 2418 2444 tinyexec@1.0.2: {} 2419 2445 2420 2446 tinypool@2.0.0: {} 2447 + 2448 + tlds@1.261.0: {} 2421 2449 2422 2450 to-regex-range@5.0.1: 2423 2451 dependencies: