diff --git a/deno.jsonc b/deno.jsonc index 63f15af..423c853 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,18 +1,23 @@ { "compilerOptions": { "jsx": "react-jsx", - "jsxImportSource": "preact" + "jsxImportSource": "preact", + "noUncheckedIndexedAccess": true }, "fmt": { "semiColons": false }, "imports": { + "@badgateway/oauth2-client": "npm:@badgateway/oauth2-client@^3.3.1", "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.1.2", "@hey-api/openapi-ts": "npm:@hey-api/openapi-ts@^0.97.0", "@hey-api/vite-plugin": "npm:@hey-api/vite-plugin@^0.3.1", "@kysely/kysely": "jsr:@kysely/kysely@^0.28.16", "@skyware/jetstream": "npm:@skyware/jetstream@^0.2.5", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/http": "jsr:@std/http@^1.1.0", "fresh": "jsr:@fresh/core@^2.3.3", + "jose": "npm:jose@^6.2.3", "mysql2": "npm:mysql2@^3.22.3", "preact": "npm:preact@^10.29.1", "vite": "npm:vite@^7.3.2" diff --git a/deno.lock b/deno.lock index 20b5184..fbc48cd 100644 --- a/deno.lock +++ b/deno.lock @@ -10,13 +10,18 @@ "jsr:@fresh/plugin-vite@^1.1.2": "1.1.2", "jsr:@kysely/kysely@~0.28.16": "0.28.16", "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/cli@^1.0.29": "1.0.29", "jsr:@std/dotenv@~0.225.5": "0.225.6", "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.10": "1.0.10", "jsr:@std/fmt@^1.0.7": "1.0.10", "jsr:@std/fmt@^1.0.8": "1.0.10", "jsr:@std/fs@^1.0.19": "1.0.23", + "jsr:@std/fs@^1.0.23": "1.0.23", "jsr:@std/html@^1.0.5": "1.0.6", + "jsr:@std/html@^1.0.6": "1.0.6", "jsr:@std/http@^1.0.21": "1.1.0", + "jsr:@std/http@^1.1.0": "1.1.0", "jsr:@std/internal@^1.0.12": "1.0.13", "jsr:@std/json@^1.0.2": "1.1.0", "jsr:@std/jsonc@^1.0.2": "1.0.2", @@ -28,9 +33,11 @@ "jsr:@std/path@^1.1.2": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/semver@^1.0.6": "1.0.8", + "jsr:@std/streams@^1.1.0": "1.1.0", "jsr:@std/uuid@^1.0.9": "1.1.1", "npm:@babel/core@^7.28.0": "7.29.0", "npm:@babel/preset-react@^7.27.1": "7.28.5_@babel+core@7.29.0", + "npm:@badgateway/oauth2-client@^3.3.1": "3.3.1", "npm:@hey-api/openapi-ts@0.97": "0.97.0_typescript@6.0.3", "npm:@hey-api/vite-plugin@~0.3.1": "0.3.1_@hey-api+openapi-ts@0.97.0__typescript@6.0.3_vite@7.3.2__@types+node@25.6.0_typescript@6.0.3", "npm:@opentelemetry/api@^1.9.0": "1.9.1", @@ -42,6 +49,7 @@ "npm:esbuild-wasm@~0.25.11": "0.25.12", "npm:esbuild@0.25.7": "0.25.7", "npm:esbuild@~0.25.5": "0.25.12", + "npm:jose@^6.2.3": "6.2.3", "npm:mysql2@^3.22.3": "3.22.3_@types+node@25.6.0", "npm:preact-render-to-string@^6.6.3": "6.6.7_preact@10.29.1", "npm:preact@^10.28.2": "10.29.1", @@ -78,9 +86,9 @@ "jsr:@fresh/build-id", "jsr:@std/encoding", "jsr:@std/fmt@^1.0.8", - "jsr:@std/fs", - "jsr:@std/html", - "jsr:@std/http", + "jsr:@std/fs@^1.0.19", + "jsr:@std/html@^1.0.5", + "jsr:@std/http@^1.0.21", "jsr:@std/jsonc", "jsr:@std/media-types@^1.1.0", "jsr:@std/net", @@ -121,6 +129,9 @@ "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, + "@std/cli@1.0.29": { + "integrity": "fa4ef29130baa834d8a13b7d138240c3a2fcfba740bfb7afa646a360a15ec84f" + }, "@std/dotenv@0.225.6": { "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" }, @@ -142,7 +153,15 @@ "@std/http@1.1.0": { "integrity": "265cd9a589fea924c5bb0bbed8bebb4bb2fa19129f760bd014e78dbd7a365a51", "dependencies": [ - "jsr:@std/encoding" + "jsr:@std/cli", + "jsr:@std/encoding", + "jsr:@std/fmt@^1.0.10", + "jsr:@std/fs@^1.0.23", + "jsr:@std/html@^1.0.6", + "jsr:@std/media-types@^1.1.0", + "jsr:@std/net", + "jsr:@std/path@^1.1.4", + "jsr:@std/streams" ] }, "@std/internal@1.0.13": { @@ -172,6 +191,9 @@ "@std/semver@1.0.8": { "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" }, + "@std/streams@1.1.0": { + "integrity": "2f7024d841f343fd478afe0c958a3f0f068ef2a0d2bcc954f550f97ac1fa22e3" + }, "@std/uuid@1.1.1": { "integrity": "7b49060282d90f72fec64952f8c7a2914cbe6fdba3eca665f8cedc90830154a7", "dependencies": [ @@ -392,6 +414,9 @@ "@babel/helper-validator-identifier" ] }, + "@badgateway/oauth2-client@3.3.1": { + "integrity": "sha512-7R4mZocEt8nOIMCz9cQyxsrY7n/jhFW9YUW6Er9ySnBzH92wA0KmQUR1cT2encq2Lix6kRD2GyQRAmT1dWtwzg==" + }, "@esbuild/aix-ppc64@0.25.12": { "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "os": ["aix"], @@ -1399,6 +1424,9 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "bin": true }, + "jose@6.2.3": { + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==" + }, "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, @@ -1670,9 +1698,13 @@ "jsr:@fresh/core@^2.3.3", "jsr:@fresh/plugin-vite@^1.1.2", "jsr:@kysely/kysely@~0.28.16", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/http@^1.1.0", + "npm:@badgateway/oauth2-client@^3.3.1", "npm:@hey-api/openapi-ts@0.97", "npm:@hey-api/vite-plugin@~0.3.1", "npm:@skyware/jetstream@~0.2.5", + "npm:jose@^6.2.3", "npm:mysql2@^3.22.3", "npm:preact@^10.29.1", "npm:vite@^7.3.2" diff --git a/lib/define.ts b/lib/define.ts index 77a71d9..01b8de0 100644 --- a/lib/define.ts +++ b/lib/define.ts @@ -1,3 +1,7 @@ import { createDefine } from "fresh" -export const define = createDefine() +interface State { + userId?: string +} + +export const define = createDefine() diff --git a/lib/oauth2.ts b/lib/oauth2.ts new file mode 100644 index 0000000..5b81b50 --- /dev/null +++ b/lib/oauth2.ts @@ -0,0 +1,7 @@ +import { OAuth2Client } from "@badgateway/oauth2-client" + +export const oauth2Client = new OAuth2Client({ + server: "https://q.trap.jp/api/v3/oauth2", + clientId: Deno.env.get("OAUTH_CLIENT_ID")!, + clientSecret: Deno.env.get("OAUTH_CLIENT_SECRET")!, +}) diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..91d8e80 --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,42 @@ +import { decodeBase64 } from "@std/encoding" +import { getCookies, setCookie } from "@std/http" +import { jwtVerify, SignJWT } from "jose" + +const secret = decodeBase64( + Deno.env.get("QONSTELLATION_JWT_SECRET")!, +) +const COOKIE_NAME = "qonstellation_session" + +export const createSessionToken = (userId: string) => + new SignJWT({ userId }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("24h") + .sign(secret) + +export const verifySessionToken = async ( + token: string, +) => { + try { + const { payload } = await jwtVerify(token, secret) + + if (typeof payload.userId === "string") { + return payload.userId + } + } catch { + return undefined + } +} + +export const getSessionToken = (req: Request) => + getCookies(req.headers)[COOKIE_NAME] + +export const setSessionToken = (headers: Headers, token: string) => + setCookie(headers, { + name: COOKIE_NAME, + value: token, + httpOnly: true, + secure: true, + sameSite: "Lax", + path: "/", + }) diff --git a/repository/user.ts b/repository/user.ts index 805ab46..d9d1500 100644 --- a/repository/user.ts +++ b/repository/user.ts @@ -33,3 +33,23 @@ export const getUserAccessToken = async (userId: string) => { return result.accessToken } + +export const saveUser = async (userId: string) => { + await db + .insertInto("users") + .values({ id: userId }) + .onDuplicateKeyUpdate({ id: userId }) + .execute() +} + +export const saveUserTokens = async ( + userId: string, + accessToken: string, + refreshToken: string, +) => { + await db + .insertInto("userTokens") + .values({ userId, accessToken, refreshToken }) + .onDuplicateKeyUpdate({ accessToken, refreshToken }) + .execute() +} diff --git a/routes/_middleware.ts b/routes/_middleware.ts new file mode 100644 index 0000000..fc2a3da --- /dev/null +++ b/routes/_middleware.ts @@ -0,0 +1,12 @@ +import { define } from "../lib/define.ts" +import { getSessionToken, verifySessionToken } from "../lib/session.ts" + +export const handler = define.middleware(async (ctx) => { + const token = getSessionToken(ctx.req) + + if (token) { + ctx.state.userId = await verifySessionToken(token) + } + + return ctx.next() +}) diff --git a/routes/callback.ts b/routes/callback.ts new file mode 100644 index 0000000..fc05fb9 --- /dev/null +++ b/routes/callback.ts @@ -0,0 +1,37 @@ +import { define } from "../lib/define.ts" +import { oauth2Client } from "../lib/oauth2.ts" +import { createSessionToken, setSessionToken } from "../lib/session.ts" +import { saveUser, saveUserTokens } from "../repository/user.ts" +import { getMe } from "../traq/index.ts" + +export const handler = define.handlers({ + GET: async (ctx) => { + const url = new URL(ctx.req.url) + const redirectUri = `${url.origin}/callback` + + const token = await oauth2Client.authorizationCode.getTokenFromCodeRedirect( + ctx.req.url, + { redirectUri }, + ) + + const { data: user } = await getMe({ + headers: { + Authorization: `Bearer ${token.accessToken}`, + }, + }) + + if (!user) { + throw new Error("Failed to get user info from traQ") + } + + await saveUser(user.id) + await saveUserTokens(user.id, token.accessToken, token.refreshToken!) + + const sessionToken = await createSessionToken(user.id) + const headers = new Headers({ Location: "/" }) + + setSessionToken(headers, sessionToken) + + return new Response(null, { status: 302, headers }) + }, +}) diff --git a/routes/index.tsx b/routes/index.tsx index 482a100..c6ffec2 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,3 +1,21 @@ +import { page } from "fresh" import { define } from "../lib/define.ts" +import { oauth2Client } from "../lib/oauth2.ts" -export default define.page(() =>
Hello, world!
) +export const handler = define.handlers({ + GET: async (ctx) => { + const url = new URL(ctx.req.url) + const redirectUri = `${url.origin}/callback` + const authorizeUrl = await oauth2Client.authorizationCode.getAuthorizeUri({ + redirectUri, + }) + + return page({ authorizeUrl }) + }, +}) + +export default define.page((ctx) => ( +
+ Login with traQ +
+))