diff --git a/server/auth/verifyResourceAccessToken.ts b/server/auth/verifyResourceAccessToken.ts index 18a5ee78..91b11bd1 100644 --- a/server/auth/verifyResourceAccessToken.ts +++ b/server/auth/verifyResourceAccessToken.ts @@ -3,10 +3,63 @@ import { Resource, ResourceAccessToken, resourceAccessToken, + resources } from "@server/db/schemas"; import { and, eq } from "drizzle-orm"; import { isWithinExpirationDate } from "oslo"; import { verifyPassword } from "./password"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; + +export async function verifyResourceAccessTokenSHA256({ + accessToken +}: { + accessToken: string; +}): Promise<{ + valid: boolean; + error?: string; + tokenItem?: ResourceAccessToken; + resource?: Resource; +}> { + const accessTokenHash = encodeHexLowerCase( + sha256(new TextEncoder().encode(accessToken)) + ); + + const [res] = await db + .select() + .from(resourceAccessToken) + .where(and(eq(resourceAccessToken.tokenHash, accessTokenHash))) + .innerJoin( + resources, + eq(resourceAccessToken.resourceId, resources.resourceId) + ); + + const tokenItem = res?.resourceAccessToken; + const resource = res?.resources; + + if (!tokenItem || !resource) { + return { + valid: false, + error: "Access token does not exist for resource" + }; + } + + if ( + tokenItem.expiresAt && + !isWithinExpirationDate(new Date(tokenItem.expiresAt)) + ) { + return { + valid: false, + error: "Access token has expired" + }; + } + + return { + valid: true, + tokenItem, + resource + }; +} export async function verifyResourceAccessToken({ resource, diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 87468c2b..7c9892bf 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.1.0"; +export const APP_VERSION = "1.2.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index f1ee763d..af8e32a2 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -20,6 +20,8 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { createDate, TimeSpan } from "oslo"; import { hashPassword } from "@server/auth/password"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; export const generateAccessTokenBodySchema = z .object({ @@ -90,11 +92,13 @@ export async function generateAccessToken( ? createDate(new TimeSpan(validForSeconds, "s")).getTime() : undefined; - const token = generateIdFromEntropySize(25); + const token = generateIdFromEntropySize(12); - const tokenHash = await hashPassword(token); + const tokenHash = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)) + ); - const id = generateId(15); + const id = generateId(8); const [result] = await db .insert(resourceAccessToken) .values({ diff --git a/server/routers/external.ts b/server/routers/external.ts index 39d283d7..c93bed33 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -566,3 +566,8 @@ authRouter.post( "/resource/:resourceId/access-token", resource.authWithAccessToken ); + +authRouter.post( + "/access-token", + resource.authWithAccessToken +); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index b9b8f7ee..d83bc40e 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -1,6 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; -import { resources } from "@server/db/schemas"; +import { Resource, resources } from "@server/db/schemas"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -10,13 +10,17 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; -import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { + verifyResourceAccessToken, + verifyResourceAccessTokenSHA256 +} from "@server/auth/verifyResourceAccessToken"; import config from "@server/lib/config"; +import stoi from "@server/lib/stoi"; const authWithAccessTokenBodySchema = z .object({ accessToken: z.string(), - accessTokenId: z.string() + accessTokenId: z.string().optional() }) .strict(); @@ -24,13 +28,15 @@ const authWithAccessTokenParamsSchema = z .object({ resourceId: z .string() - .transform(Number) - .pipe(z.number().int().positive()) + .optional() + .transform(stoi) + .pipe(z.number().int().positive().optional()) }) .strict(); export type AuthWithAccessTokenResponse = { session?: string; + redirectUrl?: string | null; }; export async function authWithAccessToken( @@ -64,23 +70,62 @@ export async function authWithAccessToken( const { accessToken, accessTokenId } = parsedBody.data; try { - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); + let valid; + let tokenItem; + let error; + let resource: Resource | undefined; - if (!resource) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Resource not found") - ); + if (accessTokenId) { + if (!resourceId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Resource ID is required" + ) + ); + } + + const [foundResource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!foundResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const res = await verifyResourceAccessToken({ + resource: foundResource, + accessTokenId, + accessToken + }); + + valid = res.valid; + tokenItem = res.tokenItem; + error = res.error; + resource = foundResource; + } else { + const res = await verifyResourceAccessTokenSHA256({ + accessToken + }); + + valid = res.valid; + tokenItem = res.tokenItem; + error = res.error; + resource = res.resource; } - const { valid, error, tokenItem } = await verifyResourceAccessToken({ - resource, - accessTokenId, - accessToken - }); + if (!tokenItem || !resource) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Access token does not exist for resource" + ) + ); + } if (!valid) { if (config.getRawConfig().app.log_failed_attempts) { @@ -96,18 +141,9 @@ export async function authWithAccessToken( ); } - if (!tokenItem || !resource) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - "Access token does not exist for resource" - ) - ); - } - const token = generateSessionToken(); await createResourceSession({ - resourceId, + resourceId: resource.resourceId, token, accessTokenId: tokenItem.accessTokenId, isRequestToken: true, @@ -118,7 +154,8 @@ export async function authWithAccessToken( return response(res, { data: { - session: token + session: token, + redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` }, success: true, error: false, diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index abcaf9c9..77248f62 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -18,6 +18,7 @@ import m13 from "./scripts/1.0.0-beta13"; import m15 from "./scripts/1.0.0-beta15"; import m16 from "./scripts/1.0.0"; import m17 from "./scripts/1.1.0"; +import m18 from "./scripts/1.2.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -35,7 +36,8 @@ const migrations = [ { version: "1.0.0-beta.13", run: m13 }, { version: "1.0.0-beta.15", run: m15 }, { version: "1.0.0", run: m16 }, - { version: "1.1.0", run: m17 } + { version: "1.1.0", run: m17 }, + { version: "1.2.0", run: m18 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.2.0.ts b/server/setup/scripts/1.2.0.ts new file mode 100644 index 00000000..bf87cd2e --- /dev/null +++ b/server/setup/scripts/1.2.0.ts @@ -0,0 +1,23 @@ +import db from "@server/db"; +import { sql } from "drizzle-orm"; + +const version = "1.2.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + db.transaction((trx) => { + trx.run( + sql`ALTER TABLE 'resources' ADD 'enabled' integer DEFAULT true NOT NULL;` + ); + }); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index fcae3741..0e647668 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -224,21 +224,18 @@ export default function CreateShareLinkForm({ if (res && res.data.data.accessTokenId) { const token = res.data.data; - const link = constructShareLink( - values.resourceId, - token.accessTokenId, - token.accessToken - ); + const link = constructShareLink(token.accessToken); setLink(link); const directLink = constructDirectShareLink( env.server.resourceAccessTokenParam, values.resourceUrl, - token.accessTokenId, token.accessToken ); setDirectLink(directLink); - const resource = resources.find((r) => r.resourceId === values.resourceId); + const resource = resources.find( + (r) => r.resourceId === values.resourceId + ); onCreated?.({ accessTokenId: token.accessTokenId, @@ -247,7 +244,7 @@ export default function CreateShareLinkForm({ title: token.title, createdAt: token.createdAt, expiresAt: token.expiresAt, - siteName: resource?.siteName || null, + siteName: resource?.siteName || null }); } diff --git a/src/app/auth/resource/[resourceId]/AccessToken.tsx b/src/app/auth/resource/[resourceId]/AccessToken.tsx index b70d75cb..69696e9d 100644 --- a/src/app/auth/resource/[resourceId]/AccessToken.tsx +++ b/src/app/auth/resource/[resourceId]/AccessToken.tsx @@ -15,15 +15,13 @@ import Link from "next/link"; import { useEffect, useState } from "react"; type AccessTokenProps = { - accessTokenId: string | undefined; - accessToken: string | undefined; - resourceId: number; - redirectUrl: string; + token: string; + resourceId?: number; + redirectUrl?: string; }; export default function AccessToken({ - accessTokenId, - accessToken, + token, resourceId, redirectUrl }: AccessTokenProps) { @@ -43,11 +41,49 @@ export default function AccessToken({ } useEffect(() => { - if (!accessTokenId || !accessToken) { + if (!token) { setLoading(false); return; } + let accessTokenId = ""; + let accessToken = ""; + + const parts = token.split("."); + + if (parts.length === 2) { + accessTokenId = parts[0]; + accessToken = parts[1]; + } else if (parts.length === 1) { + accessToken = parts[0]; + } else { + setLoading(false); + return; + } + + async function checkSHA256() { + try { + const res = await api.post< + AxiosResponse + >(`/auth/access-token`, { + accessToken, + accessTokenId + }); + + if (res.data.data.session) { + setIsValid(true); + window.location.href = appendRequestToken( + res.data.data.redirectUrl!, + res.data.data.session + ); + } + } catch (e) { + console.error("Error checking access token", e); + } finally { + setLoading(false); + } + } + async function check() { try { const res = await api.post< @@ -60,7 +96,7 @@ export default function AccessToken({ if (res.data.data.session) { setIsValid(true); window.location.href = appendRequestToken( - redirectUrl, + redirectUrl!, res.data.data.session ); } @@ -71,8 +107,13 @@ export default function AccessToken({ } } - check(); - }, [accessTokenId, accessToken]); + if (!accessTokenId) { + // no access token id so check the sha256 + checkSHA256(); + } else { + check(); + } + }, [token]); function renderTitle() { if (isValid) { diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index cc576a03..3cf56b10 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -118,12 +118,10 @@ export default async function ResourceAuthPage(props: { } if (searchParams.token) { - const [accessTokenId, accessToken] = searchParams.token.split("."); return (
diff --git a/src/app/s/[accessToken]/page.tsx b/src/app/s/[accessToken]/page.tsx new file mode 100644 index 00000000..d28ff7be --- /dev/null +++ b/src/app/s/[accessToken]/page.tsx @@ -0,0 +1,13 @@ +import AccessToken from "@app/app/auth/resource/[resourceId]/AccessToken"; + +export default async function ResourceAuthPage(props: { + params: Promise<{ accessToken: string }>; +}) { + const params = await props.params; + + return ( +
+ +
+ ); +} diff --git a/src/lib/shareLinks.ts b/src/lib/shareLinks.ts index 94c292ad..ca79b116 100644 --- a/src/lib/shareLinks.ts +++ b/src/lib/shareLinks.ts @@ -1,18 +1,13 @@ -import { pullEnv } from "./pullEnv"; - export function constructShareLink( - resourceId: number, - id: string, token: string ) { - return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`; + return `${window.location.origin}/s/${token!}`; } export function constructDirectShareLink( param: string, resourceUrl: string, - id: string, token: string ) { - return `${resourceUrl}?${param}=${id}.${token}`; + return `${resourceUrl}?${param}=${token}`; }