+33
-35
apps/web/client.ts
+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
+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
+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
+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
+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
packages/database/types.ts
+28
pnpm-lock.yaml
+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: