diff --git a/package.json b/package.json index fab5659..a2854cc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@atcute/identity-resolver-node": "^1.0.3", "@atcute/lexicons": "^1.2.9", "@atcute/oauth-node-client": "^1.1.0", - "@types/node": "^25.3.3" + "@types/node": "^25.3.3", + "valibot": "^1.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa45891..bef3b59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@types/node': specifier: ^25.3.3 version: 25.3.3 + valibot: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.9.3) devDependencies: '@sveltejs/adapter-auto': specifier: ^7.0.0 @@ -637,6 +640,14 @@ packages: unicode-segmenter@0.14.5: resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1190,6 +1201,10 @@ snapshots: unicode-segmenter@0.14.5: {} + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + vite@7.3.1(@types/node@25.3.3): dependencies: esbuild: 0.27.3 diff --git a/src/app.d.ts b/src/app.d.ts index 378d7be..bd4145c 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,5 @@ -import type { OAuthClient, OAuthSession } from "@atcute/oauth-node-client"; -import type { Client } from "@atcute/client"; -import type { Did, Handle } from "@atcute/lexicons"; +import type { OAuthClient } from "@atcute/oauth-node-client"; +import type { PrivateUserData } from "$lib/valibot.ts"; // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces @@ -9,13 +8,7 @@ declare global { // interface Error {} interface Locals { oAuthClient?: OAuthClient; - user?: { - client: Client; - session: OAuthSession; - did: Did; - handle: Handle; - displayName: string; - }; + user?: PrivateUserData; } // interface PageData {} // interface PageState {} diff --git a/src/lib/server/constants.ts b/src/lib/server/constants.ts index dfd3bdd..7a57c0e 100644 --- a/src/lib/server/constants.ts +++ b/src/lib/server/constants.ts @@ -1,3 +1,5 @@ +export const OAUTH_MAX_AGE = 60 * 10; +export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; +export const OAUTH_COOKIE_PREFIX = "atproto_oauth_"; export const SESSION_COOKIE = "atproto_session"; export const HANDLE_COOKIE = "atproto_handle"; -export const OAUTH_COOKIE_PREFIX = "atproto_oauth_"; diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts index 2b41c03..0651a3f 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/server/oauth.ts @@ -10,7 +10,11 @@ import { import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; import { OAuthClient, scope, type Store } from "@atcute/oauth-node-client"; import { decryptText, encryptText } from "$lib/server/crypto.ts"; -import { OAUTH_COOKIE_PREFIX } from "$lib/server/constants.ts"; +import { + OAUTH_COOKIE_PREFIX, + OAUTH_MAX_AGE, + SESSION_MAX_AGE, +} from "$lib/server/constants.ts"; import { env } from "$env/dynamic/private"; import { dev } from "$app/environment"; import { Buffer } from "node:buffer"; @@ -18,7 +22,7 @@ import { Buffer } from "node:buffer"; class CookieStore implements Store { #cookies: Cookies; #prefix = OAUTH_COOKIE_PREFIX; - #maxAge = 60 * 60 * 24 * 7; + #maxAge = SESSION_MAX_AGE; constructor(event: { cookies: Cookies }, options?: { maxAge?: number }) { this.#cookies = event.cookies; @@ -116,7 +120,7 @@ export function createOAuthClient( }), stores: { sessions: new CookieStore(event), - states: new CookieStore(event, { maxAge: 60 * 10 }), + states: new CookieStore(event, { maxAge: OAUTH_MAX_AGE }), }, }); diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts index 2a9d073..b3a33ca 100644 --- a/src/lib/server/session.ts +++ b/src/lib/server/session.ts @@ -1,11 +1,16 @@ import type { RequestEvent } from "@sveltejs/kit"; import { Client } from "@atcute/client"; -import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import { isHandle } from "@atcute/lexicons/syntax"; import { createOAuthClient } from "$lib/server/oauth.ts"; -import { decryptText } from "./crypto.ts"; +import { decryptText } from "$lib/server/crypto.ts"; +import { + HANDLE_COOKIE, + OAUTH_MAX_AGE, + SESSION_COOKIE, +} from "$lib/server/constants.ts"; +import { parsePublicUser, type PublicUserData } from "$lib/valibot.ts"; import { dev } from "$app/environment"; import { env } from "$env/dynamic/private"; -import { HANDLE_COOKIE, SESSION_COOKIE } from "./constants.ts"; /** * Logout @@ -47,7 +52,7 @@ export const startSession = async ( handle, { httpOnly: true, - maxAge: 60 * 10, + maxAge: OAUTH_MAX_AGE, path: "/", sameSite: "lax", secure: !dev, @@ -66,19 +71,15 @@ export const restoreSession = async (event: RequestEvent): Promise => { return; } // Parse and validate or delete cookie - let data; + let data: PublicUserData; try { const decrypted = await decryptText(encrypted, env.PRIVATE_COOKIE_KEY); - data = JSON.parse(decrypted); + data = parsePublicUser(JSON.parse(decrypted)); } catch { cookies.delete(SESSION_COOKIE, { path: "/" }); return; } - // [TODO] ArkType data validation? try { - if (isDid(data.did) === false || isHandle(data.handle) === false) { - throw new Error(); - } const oAuthClient = createOAuthClient(event); const session = await oAuthClient.restore(data.did); const client = new Client({ handler: session }); diff --git a/src/lib/valibot.ts b/src/lib/valibot.ts new file mode 100644 index 0000000..0c07143 --- /dev/null +++ b/src/lib/valibot.ts @@ -0,0 +1,29 @@ +import * as v from "valibot"; +import { Client } from "@atcute/client"; +import { OAuthSession } from "@atcute/oauth-node-client"; +import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import type { Did, Handle } from "@atcute/lexicons"; + +const UserSchema = { + did: v.custom(isDid, "invalid did"), + handle: v.custom(isHandle, "invalid handle"), + displayName: v.string(), +}; + +export const PublicUserSchema = v.object(UserSchema); +export type PublicUserData = v.InferOutput; + +export const PrivateUserSchema = v.object({ + ...UserSchema, + client: v.instance(Client), + session: v.instance(OAuthSession), +}); +export type PrivateUserData = v.InferOutput; + +export function parsePublicUser(data: unknown): PublicUserData { + return v.parse(PublicUserSchema, data); +} + +export function parsePrivateUser(data: unknown): PrivateUserData { + return v.parse(PrivateUserSchema, data); +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 0b8a154..fe1c3bc 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,7 +1,8 @@ import type { LayoutServerLoad } from "./$types"; +import type { PublicUserData } from "$lib/valibot.ts"; export const load: LayoutServerLoad = (event) => { - let user = undefined; + let user: PublicUserData | undefined = undefined; if (event.locals.user) { user = { did: event.locals.user.did, diff --git a/src/routes/oauth/callback/+server.ts b/src/routes/oauth/callback/+server.ts index b590983..fed3e68 100644 --- a/src/routes/oauth/callback/+server.ts +++ b/src/routes/oauth/callback/+server.ts @@ -8,12 +8,14 @@ import { encryptText } from "$lib/server/crypto.ts"; import { HANDLE_COOKIE, SESSION_COOKIE } from "$lib/server/constants.ts"; import { env } from "$env/dynamic/private"; import { dev } from "$app/environment"; +import { isHandle } from "@atcute/lexicons/syntax"; +import type { PublicUserData } from "$lib/valibot.ts"; export const GET: RequestHandler = async (event) => { const { url, cookies } = event; const handle = cookies.get(HANDLE_COOKIE); - if (handle === undefined) { + if (isHandle(handle) === false) { return redirect(303, "/?error=expired"); } cookies.delete(HANDLE_COOKIE, { path: "/" }); @@ -27,7 +29,7 @@ export const GET: RequestHandler = async (event) => { redirect(303, "/?error=session"); } - const data = { + const data: PublicUserData = { handle, did: session.did, displayName: handle,