+3
-3
apps/web/client.ts
+3
-3
apps/web/client.ts
···
22
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",
···
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
);
43
···
22
23
const metadata: OAuthClientMetadataInput = PUBLIC_URL
24
? {
25
+
client_name: "Disco Stu - 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",
30
grant_types: ["authorization_code", "refresh_token"],
31
response_types: ["code"],
32
application_type: "web",
···
37
: atprotoLoopbackClientMetadata(
38
`http://localhost?${new URLSearchParams([
39
["redirect_uri", "http://127.0.0.1:8002/oauth/callback"],
40
+
["scope", "atproto"],
41
])}`,
42
);
43
+27
-5
apps/web/index.ts
+27
-5
apps/web/index.ts
···
1
-
import { isValidHandle } from "@atproto/syntax";
2
import { serve, type HttpBindings } from "@hono/node-server";
3
import { COOKIE_SECRET } from "@tealfmbot/common/constants.js";
4
import { logger } from "@tealfmbot/common/logger.js";
5
import { Hono } from "hono";
6
import { getSignedCookie } from "hono/cookie";
7
import { validator } from "hono/validator";
8
import pinoHttpLogger from "pino-http";
9
10
import { client } from "./client";
11
-
import { createSession, MAX_AGE } from "./utils";
12
13
type Variables = {
14
logger: typeof logger;
···
69
return c.redirect("/");
70
});
71
72
app.post(
73
"/login",
74
validator("form", (value, c) => {
75
const identifier = value["identifier"];
76
-
if (!identifier || typeof identifier !== "string" || !isValidHandle(identifier)) {
77
return c.json({ message: "Invalid handle, did or PDS URL" }, 400);
78
}
79
···
81
identifier,
82
};
83
}),
84
-
(c) => {
85
c.header("Cache-Control", "no-store");
86
const { identifier } = c.req.valid("form");
87
try {
88
-
return c.text(identifier);
89
} catch (error) {
90
logger.error({ error }, "oauth authorize failed");
91
return c.json({ message: "oauth authorize failed" }, 500);
···
1
import { serve, type HttpBindings } from "@hono/node-server";
2
import { COOKIE_SECRET } from "@tealfmbot/common/constants.js";
3
import { logger } from "@tealfmbot/common/logger.js";
4
import { Hono } from "hono";
5
import { getSignedCookie } from "hono/cookie";
6
+
import { html } from "hono/html";
7
import { validator } from "hono/validator";
8
import pinoHttpLogger from "pino-http";
9
10
import { client } from "./client";
11
+
import { createSession, MAX_AGE, validateIdentifier } from "./utils";
12
13
type Variables = {
14
logger: typeof logger;
···
69
return c.redirect("/");
70
});
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
+
84
app.post(
85
"/login",
86
validator("form", (value, c) => {
87
const identifier = value["identifier"];
88
+
if (typeof identifier !== "string" || !validateIdentifier(identifier)) {
89
return c.json({ message: "Invalid handle, did or PDS URL" }, 400);
90
}
91
···
93
identifier,
94
};
95
}),
96
+
async (c) => {
97
c.header("Cache-Control", "no-store");
98
const { identifier } = c.req.valid("form");
99
+
const ac = new AbortController();
100
try {
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);
111
} catch (error) {
112
logger.error({ error }, "oauth authorize failed");
113
return c.json({ message: "oauth authorize failed" }, 500);
+1
apps/web/package.json
+1
apps/web/package.json
+6
-6
apps/web/storage.ts
+6
-6
apps/web/storage.ts
···
15
.where("key", "=", key)
16
.executeTakeFirst();
17
if (!result) return;
18
-
return JSON.parse(result.state) as NodeSavedState;
19
}
20
async set(key: string, val: NodeSavedState) {
21
const state = JSON.stringify(val);
22
await this.db
23
.insertInto("auth_state")
24
.values({ key, state })
25
-
.onConflict((oc) => oc.doUpdateSet({ state }))
26
.execute();
27
}
28
async del(key: string) {
···
39
.where("key", "=", key)
40
.executeTakeFirst();
41
if (!result) return;
42
-
return JSON.parse(result.session) as NodeSavedSession;
43
}
44
-
async set(key: string, value: NodeSavedSession) {
45
-
const session = JSON.stringify(value);
46
await this.db
47
.insertInto("auth_session")
48
.values({ key, session })
49
-
.onConflict((oc) => oc.doUpdateSet({ session }))
50
.execute();
51
}
52
async del(key: string) {
···
15
.where("key", "=", key)
16
.executeTakeFirst();
17
if (!result) return;
18
+
return JSON.parse(result.state);
19
}
20
async set(key: string, val: NodeSavedState) {
21
const state = JSON.stringify(val);
22
await this.db
23
.insertInto("auth_state")
24
.values({ key, state })
25
+
.onConflict((oc) => oc.column("key").doUpdateSet({ state }))
26
.execute();
27
}
28
async del(key: string) {
···
39
.where("key", "=", key)
40
.executeTakeFirst();
41
if (!result) return;
42
+
return JSON.parse(result.session);
43
}
44
+
async set(key: string, val: NodeSavedSession) {
45
+
const session = JSON.stringify(val);
46
await this.db
47
.insertInto("auth_session")
48
.values({ key, session })
49
+
.onConflict((oc) => oc.column("key").doUpdateSet({ session }))
50
.execute();
51
}
52
async del(key: string) {
+1
-1
apps/web/tsconfig.json
+1
-1
apps/web/tsconfig.json
+27
-2
apps/web/utils.ts
+27
-2
apps/web/utils.ts
···
1
import type { Context } from "hono";
2
3
import { Agent } from "@atproto/api";
4
import { COOKIE_SECRET } from "@tealfmbot/common/constants.ts";
5
import { logger } from "@tealfmbot/common/logger.js";
6
import { deleteCookie, generateSignedCookie, getSignedCookie } from "hono/cookie";
···
15
secure: process.env.NODE_ENV === "development" ? false : true,
16
httpOnly: true,
17
sameSite: "lax",
18
});
19
20
return cookie;
···
29
try {
30
const oauthSession = await client.restore(session);
31
return oauthSession ? new Agent(oauthSession) : null;
32
-
} catch {
33
-
logger.warn("oauth restore failed");
34
deleteCookie(c, "__teal_fm_bot_session");
35
return null;
36
}
37
}
···
1
import type { Context } from "hono";
2
3
import { Agent } from "@atproto/api";
4
+
import { isAtprotoDid, isAtprotoDidWeb } from "@atproto/did";
5
+
import { isValidHandle } from "@atproto/syntax";
6
import { COOKIE_SECRET } from "@tealfmbot/common/constants.ts";
7
import { logger } from "@tealfmbot/common/logger.js";
8
import { deleteCookie, generateSignedCookie, getSignedCookie } from "hono/cookie";
···
17
secure: process.env.NODE_ENV === "development" ? false : true,
18
httpOnly: true,
19
sameSite: "lax",
20
+
maxAge: 60 * 60 * 24 * 7,
21
});
22
23
return cookie;
···
32
try {
33
const oauthSession = await client.restore(session);
34
return oauthSession ? new Agent(oauthSession) : null;
35
+
} catch (error) {
36
+
logger.warn({ error }, "oauth restore failed");
37
deleteCookie(c, "__teal_fm_bot_session");
38
return null;
39
}
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
+2
-2
packages/database/migrations/1766974106212_oauth.ts
···
3
export async function up(db: Kysely<any>): Promise<void> {
4
await db.schema
5
.createTable("auth_session")
6
-
.addColumn("key", "varchar", (col) => col.primaryKey())
7
.addColumn("session", "varchar", (col) => col.notNull())
8
.execute();
9
await db.schema
10
.createTable("auth_state")
11
-
.addColumn("key", "varchar", (col) => col.primaryKey())
12
.addColumn("state", "varchar", (col) => col.notNull())
13
.execute();
14
}
···
3
export async function up(db: Kysely<any>): Promise<void> {
4
await db.schema
5
.createTable("auth_session")
6
+
.addColumn("key", "varchar", (col) => col.primaryKey().unique())
7
.addColumn("session", "varchar", (col) => col.notNull())
8
.execute();
9
await db.schema
10
.createTable("auth_state")
11
+
.addColumn("key", "varchar", (col) => col.primaryKey().unique())
12
.addColumn("state", "varchar", (col) => col.notNull())
13
.execute();
14
}
+3
pnpm-lock.yaml
+3
pnpm-lock.yaml