+3
-3
apps/web/client.ts
+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
+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
+1
apps/web/package.json
+6
-6
apps/web/storage.ts
+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
+1
-1
apps/web/tsconfig.json
+27
-2
apps/web/utils.ts
+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
+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
}