diff --git a/server/auth/verifyResourceAccessToken.ts b/server/auth/verifyResourceAccessToken.ts index 91b11bd1..3f5a17de 100644 --- a/server/auth/verifyResourceAccessToken.ts +++ b/server/auth/verifyResourceAccessToken.ts @@ -11,10 +11,12 @@ import { verifyPassword } from "./password"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; -export async function verifyResourceAccessTokenSHA256({ - accessToken +export async function verifyResourceAccessToken({ + accessToken, + accessTokenId }: { accessToken: string; + accessTokenId?: string; }): Promise<{ valid: boolean; error?: string; @@ -25,17 +27,61 @@ export async function verifyResourceAccessTokenSHA256({ 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) - ); + let tokenItem: ResourceAccessToken | undefined; + let resource: Resource | undefined; - const tokenItem = res?.resourceAccessToken; - const resource = res?.resources; + if (!accessTokenId) { + const [res] = await db + .select() + .from(resourceAccessToken) + .where(and(eq(resourceAccessToken.tokenHash, accessTokenHash))) + .innerJoin( + resources, + eq(resourceAccessToken.resourceId, resources.resourceId) + ); + + tokenItem = res?.resourceAccessToken; + resource = res?.resources; + } else { + const [res] = await db + .select() + .from(resourceAccessToken) + .where(and(eq(resourceAccessToken.accessTokenId, accessTokenId))) + .innerJoin( + resources, + eq(resourceAccessToken.resourceId, resources.resourceId) + ); + + if (res && res.resourceAccessToken) { + if (res.resourceAccessToken.tokenHash?.startsWith("$argon")) { + const validCode = await verifyPassword( + accessToken, + res.resourceAccessToken.tokenHash + ); + + if (!validCode) { + return { + valid: false, + error: "Invalid access token" + }; + } + } else { + const tokenHash = encodeHexLowerCase( + sha256(new TextEncoder().encode(accessToken)) + ); + + if (res.resourceAccessToken.tokenHash !== tokenHash) { + return { + valid: false, + error: "Invalid access token" + }; + } + } + } + + tokenItem = res?.resourceAccessToken; + resource = res?.resources; + } if (!tokenItem || !resource) { return { @@ -60,61 +106,3 @@ export async function verifyResourceAccessTokenSHA256({ resource }; } - -export async function verifyResourceAccessToken({ - resource, - accessTokenId, - accessToken -}: { - resource: Resource; - accessTokenId: string; - accessToken: string; -}): Promise<{ - valid: boolean; - error?: string; - tokenItem?: ResourceAccessToken; -}> { - const [result] = await db - .select() - .from(resourceAccessToken) - .where( - and( - eq(resourceAccessToken.resourceId, resource.resourceId), - eq(resourceAccessToken.accessTokenId, accessTokenId) - ) - ) - .limit(1); - - const tokenItem = result; - - if (!tokenItem) { - return { - valid: false, - error: "Access token does not exist for resource" - }; - } - - const validCode = await verifyPassword(accessToken, tokenItem.tokenHash); - - if (!validCode) { - return { - valid: false, - error: "Invalid access token" - }; - } - - if ( - tokenItem.expiresAt && - !isWithinExpirationDate(new Date(tokenItem.expiresAt)) - ) { - return { - valid: false, - error: "Access token has expired" - }; - } - - return { - valid: true, - tokenItem - }; -} diff --git a/server/lib/config.ts b/server/lib/config.ts index fb89f62e..f6f4c447 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -66,6 +66,10 @@ const configSchema = z.object({ internal_hostname: z.string().transform((url) => url.toLowerCase()), session_cookie_name: z.string(), resource_access_token_param: z.string(), + resource_access_token_headers: z.object({ + id: z.string(), + token: z.string() + }), resource_session_request_param: z.string(), dashboard_session_length_hours: z .number() @@ -239,6 +243,10 @@ export class Config { : "false"; process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param; + process.env.RESOURCE_ACCESS_TOKEN_HEADERS_ID = + parsedConfig.data.server.resource_access_token_headers.id; + process.env.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN = + parsedConfig.data.server.resource_access_token_headers.token; process.env.RESOURCE_SESSION_REQUEST_PARAM = parsedConfig.data.server.resource_session_request_param; process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags @@ -335,13 +343,13 @@ export class Config { // update the supporter key in the database await db - .update(supporterKey) - .set({ - tier: data.data.tier || null, - phrase: data.data.cutePhrase || null, - valid: true - }) - .where(eq(supporterKey.keyId, key.keyId)); + .update(supporterKey) + .set({ + tier: data.data.tier || null, + phrase: data.data.cutePhrase || null, + valid: true + }) + .where(eq(supporterKey.keyId, key.keyId)); } catch (e) { this.supporterData = key; console.error("Failed to validate supporter key", e); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 01196a89..e6e31199 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -41,12 +41,13 @@ const cache = new NodeCache({ const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string()).optional(), + headers: z.record(z.string()).optional(), + query: z.record(z.string()).optional(), originalRequestURL: z.string().url(), scheme: z.string(), host: z.string(), path: z.string(), method: z.string(), - accessToken: z.string().optional(), tls: z.boolean(), requestIp: z.string().optional() }); @@ -85,7 +86,8 @@ export async function verifyResourceSession( originalRequestURL, requestIp, path, - accessToken: token + headers, + query } = parsedBody.data; const clientIp = requestIp?.split(":")[0]; @@ -183,12 +185,32 @@ export async function verifyResourceSession( resource.resourceId )}?redirect=${encodeURIComponent(originalRequestURL)}`; - // check for access token - let validAccessToken: ResourceAccessToken | undefined; - if (token) { - const [accessTokenId, accessToken] = token.split("."); + // check for access token in headers + if ( + headers && + headers[ + config.getRawConfig().server.resource_access_token_headers.id + ] && + headers[ + config.getRawConfig().server.resource_access_token_headers.token + ] + ) { + const accessTokenId = + headers[ + config.getRawConfig().server.resource_access_token_headers + .id + ]; + const accessToken = + headers[ + config.getRawConfig().server.resource_access_token_headers + .token + ]; + const { valid, error, tokenItem } = await verifyResourceAccessToken( - { resource, accessTokenId, accessToken } + { + accessToken, + accessTokenId + } ); if (error) { @@ -206,16 +228,43 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - validAccessToken = tokenItem; + return allowed(res); + } + } - if (!sessions) { - return await createAccessTokenSession( - res, - resource, - tokenItem + if ( + query && + query[config.getRawConfig().server.resource_access_token_param] + ) { + const token = + query[config.getRawConfig().server.resource_access_token_param]; + + const [accessTokenId, accessToken] = token.split("."); + + const { valid, error, tokenItem } = await verifyResourceAccessToken( + { + accessToken, + accessTokenId + } + ); + + if (error) { + logger.debug("Access token invalid: " + error); + } + + if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource access token is invalid. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } } + + if (valid && tokenItem) { + return allowed(res); + } } if (!sessions) { @@ -321,16 +370,6 @@ export async function verifyResourceSession( } } - // At this point we have checked all sessions, but since the access token is - // valid, we should allow access and create a new session. - if (validAccessToken) { - return await createAccessTokenSession( - res, - resource, - validAccessToken - ); - } - logger.debug("No more auth to check, resource not allowed"); if (config.getRawConfig().app.log_failed_attempts) { @@ -360,8 +399,7 @@ function extractResourceSessionToken( ssl ? "_s" : "" }`; - const all: { cookieName: string; token: string; priority: number }[] = - []; + const all: { cookieName: string; token: string; priority: number }[] = []; for (const [key, value] of Object.entries(sessions)) { const parts = key.split("."); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index d83bc40e..961b2d8a 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -11,8 +11,7 @@ import { fromError } from "zod-validation-error"; import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { - verifyResourceAccessToken, - verifyResourceAccessTokenSHA256 + verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import config from "@server/lib/config"; import stoi from "@server/lib/stoi"; @@ -98,7 +97,6 @@ export async function authWithAccessToken( } const res = await verifyResourceAccessToken({ - resource: foundResource, accessTokenId, accessToken }); @@ -108,7 +106,7 @@ export async function authWithAccessToken( error = res.error; resource = foundResource; } else { - const res = await verifyResourceAccessTokenSHA256({ + const res = await verifyResourceAccessToken({ accessToken }); diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 35264c18..17e385ed 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -110,9 +110,12 @@ export async function traefikConfigProvider( userSessionCookieName: config.getRawConfig().server .session_cookie_name, + + // deprecated accessTokenQueryParam: config.getRawConfig().server .resource_access_token_param, + resourceSessionRequestParam: config.getRawConfig().server .resource_session_request_param diff --git a/server/setup/scripts/1.2.0.ts b/server/setup/scripts/1.2.0.ts index bf87cd2e..825fc52f 100644 --- a/server/setup/scripts/1.2.0.ts +++ b/server/setup/scripts/1.2.0.ts @@ -1,5 +1,8 @@ import db from "@server/db"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; const version = "1.2.0"; @@ -19,5 +22,48 @@ export default async function migration() { throw e; } + try { + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + if (!rawConfig.flags) { + rawConfig.flags = {}; + } + + rawConfig.server.resource_access_token_headers = { + id: "P-Access-Token-ID", + token: "P-Access-Token" + }; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log(`Added new config option: resource_access_token_headers`); + } catch (e) { + console.log( + `Unable to add new config option: resource_access_token_headers. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config` + ); + console.error(e); + } + console.log(`${version} migration complete`); } diff --git a/src/app/[orgId]/settings/share-links/AccessTokenUsage.tsx b/src/app/[orgId]/settings/share-links/AccessTokenUsage.tsx new file mode 100644 index 00000000..5f44ca52 --- /dev/null +++ b/src/app/[orgId]/settings/share-links/AccessTokenUsage.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy, Info, InfoIcon } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import CopyTextBox from "@app/components/CopyTextBox"; + +interface AccessTokenSectionProps { + token: string; + tokenId: string; + resourceUrl: string; +} + +export default function AccessTokenSection({ + token, + tokenId, + resourceUrl +}: AccessTokenSectionProps) { + const { env } = useEnvContext(); + + const [copied, setCopied] = useState(null); + + const copyToClipboard = (text: string, type: string) => { + navigator.clipboard.writeText(text); + setCopied(type); + setTimeout(() => setCopied(null), 2000); + }; + + return ( + <> +
+

+ Your access token can be passed in two ways: as a query + parameter or in the request headers. These must be passed + from the client on every request for authenticated access. +

+
+ + + + Access Token + Usage Examples + + + +
+
Token ID
+ +
+ +
+
Token
+ +
+
+ + +
+

Request Headers

+ +
+ +
+

Query Parameter

+ +
+ + + + + Important Note + + + For security reasons, using headers is recommended + over query parameters when possible, as query + parameters may be logged in server logs or browser + history. + + +
+
+ +
+ Keep your access token secure. Do not share it in publicly + accessible areas or client-side code. +
+ + ); +} diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 0e647668..bc8701ad 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -4,7 +4,6 @@ import { Button } from "@app/components/ui/button"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -20,7 +19,6 @@ import { } from "@app/components/ui/select"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,7 +35,6 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import { ListRolesResponse } from "@server/routers/role"; import { formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; import { createApiClient } from "@app/lib/api"; @@ -58,12 +55,9 @@ import { CommandList } from "@app/components/ui/command"; import { CheckIcon, ChevronsUpDown } from "lucide-react"; -import { register } from "module"; -import { Label } from "@app/components/ui/label"; import { Checkbox } from "@app/components/ui/checkbox"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { - constructDirectShareLink, constructShareLink } from "@app/lib/shareLinks"; import { ShareLinkRow } from "./ShareLinksTable"; @@ -73,6 +67,7 @@ import { CollapsibleContent, CollapsibleTrigger } from "@app/components/ui/collapsible"; +import AccessTokenSection from "./AccessTokenUsage"; type FormProps = { open: boolean; @@ -100,7 +95,8 @@ export default function CreateShareLinkForm({ const api = createApiClient({ env }); const [link, setLink] = useState(null); - const [directLink, setDirectLink] = useState(null); + const [accessTokenId, setAccessTokenId] = useState(null); + const [accessToken, setAccessToken] = useState(null); const [loading, setLoading] = useState(false); const [neverExpire, setNeverExpire] = useState(false); @@ -226,12 +222,9 @@ export default function CreateShareLinkForm({ const token = res.data.data; const link = constructShareLink(token.accessToken); setLink(link); - const directLink = constructDirectShareLink( - env.server.resourceAccessTokenParam, - values.resourceUrl, - token.accessToken - ); - setDirectLink(directLink); + + setAccessToken(token.accessToken); + setAccessTokenId(token.accessTokenId); const resource = resources.find( (r) => r.resourceId === values.resourceId @@ -515,8 +508,7 @@ export default function CreateShareLinkForm({ className="p-0 flex items-center justify-between w-full" >

- See alternative share - links + See Access Token Usage

@@ -528,26 +520,21 @@ export default function CreateShareLinkForm({
- {directLink && ( + {accessTokenId && accessToken && (
-
-

- This link does not - require visiting in a - browser to complete the - redirect. It contains - the access token - directly in the URL, - which can be useful for - sharing with clients - that do not support - redirects. -

)}
diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 84d2c1c0..f078b9d7 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -617,7 +617,7 @@ PersistentKeepalive = 5`; - + Save Your Credentials @@ -777,7 +777,7 @@ PersistentKeepalive = 5`; - + Save Your Credentials diff --git a/src/components/CopyTextBox.tsx b/src/components/CopyTextBox.tsx index 6e9ba279..b6c838ec 100644 --- a/src/components/CopyTextBox.tsx +++ b/src/components/CopyTextBox.tsx @@ -32,7 +32,7 @@ export default function CopyTextBox({ >
{text}