add pass access token in headers

This commit is contained in:
miloschwartz 2025-04-05 22:28:47 -04:00
parent 74d6b3d902
commit 6cc4bc2645
No known key found for this signature in database
14 changed files with 333 additions and 161 deletions

View file

@ -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,6 +27,10 @@ export async function verifyResourceAccessTokenSHA256({
sha256(new TextEncoder().encode(accessToken))
);
let tokenItem: ResourceAccessToken | undefined;
let resource: Resource | undefined;
if (!accessTokenId) {
const [res] = await db
.select()
.from(resourceAccessToken)
@ -34,8 +40,48 @@ export async function verifyResourceAccessTokenSHA256({
eq(resourceAccessToken.resourceId, resources.resourceId)
);
const tokenItem = res?.resourceAccessToken;
const resource = res?.resources;
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
};
}

View file

@ -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

View file

@ -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(".");

View file

@ -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
});

View file

@ -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

View file

@ -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`);
}

View file

@ -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<string | null>(null);
const copyToClipboard = (text: string, type: string) => {
navigator.clipboard.writeText(text);
setCopied(type);
setTimeout(() => setCopied(null), 2000);
};
return (
<>
<div className="flex items-start space-x-2">
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<Tabs defaultValue="token" className="w-full mt-4">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="token">Access Token</TabsTrigger>
<TabsTrigger value="usage">Usage Examples</TabsTrigger>
</TabsList>
<TabsContent value="token" className="space-y-4">
<div className="space-y-1">
<div className="font-bold">Token ID</div>
<CopyToClipboard text={tokenId} isLink={false} />
</div>
<div className="space-y-1">
<div className="font-bold">Token</div>
<CopyToClipboard text={token} isLink={false} />
</div>
</TabsContent>
<TabsContent value="usage" className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Request Headers</h3>
<CopyTextBox
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
/>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Query Parameter</h3>
<CopyTextBox
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
/>
</div>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Important Note
</AlertTitle>
<AlertDescription>
For security reasons, using headers is recommended
over query parameters when possible, as query
parameters may be logged in server logs or browser
history.
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<div className="text-sm text-muted-foreground mt-4">
Keep your access token secure. Do not share it in publicly
accessible areas or client-side code.
</div>
</>
);
}

View file

@ -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<string | null>(null);
const [directLink, setDirectLink] = useState<string | null>(null);
const [accessTokenId, setAccessTokenId] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(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"
>
<h4 className="text-sm font-semibold">
See alternative share
links
See Access Token Usage
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
@ -528,26 +520,21 @@ export default function CreateShareLinkForm({
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2">
{directLink && (
{accessTokenId && accessToken && (
<div className="space-y-2">
<div className="mx-auto">
<CopyTextBox
text={directLink}
wrapText={false}
<AccessTokenSection
tokenId={
accessTokenId
}
token={accessToken}
resourceUrl={
form.getValues(
"resourceUrl"
)
}
/>
</div>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
)}
</CollapsibleContent>

View file

@ -617,7 +617,7 @@ PersistentKeepalive = 5`;
</InfoSection>
</InfoSections>
<Alert variant="default" className="">
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your Credentials
@ -777,7 +777,7 @@ PersistentKeepalive = 5`;
<SettingsSectionBody>
<CopyTextBox text={wgConfig} />
<Alert variant="default">
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your Credentials

View file

@ -32,7 +32,7 @@ export default function CopyTextBox({
>
<pre
ref={textRef}
className={`p-4 pr-16 text-sm w-full ${
className={`p-2 pr-16 text-sm w-full ${
wrapText
? "whitespace-pre-wrap break-words"
: "overflow-x-auto"
@ -41,10 +41,10 @@ export default function CopyTextBox({
<code className="block w-full">{text}</code>
</pre>
<Button
variant="outline"
size="icon"
variant="ghost"
size="sm"
type="button"
className="absolute top-1 right-1 z-10 bg-card"
className="absolute top-0.5 right-0 z-10 bg-card"
onClick={copyToClipboard}
aria-label="Copy to clipboard"
>

View file

@ -4,15 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const alertVariants = cva(
"relative w-full rounded-lg border-2 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
"relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-card text-foreground",
default: "bg-card border text-foreground",
neutral: "bg-card border-2 text-foreground",
destructive:
"border-destructive/50 bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
"border-destructive/50 border-2 bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
"border-green-500/50 border-2 bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
},
},
defaultVariants: {

View file

@ -9,12 +9,16 @@ export function pullEnv(): Env {
resourceAccessTokenParam: process.env
.RESOURCE_ACCESS_TOKEN_PARAM as string,
resourceSessionRequestParam: process.env
.RESOURCE_SESSION_REQUEST_PARAM as string
.RESOURCE_SESSION_REQUEST_PARAM as string,
resourceAccessTokenHeadersId: process.env
.RESOURCE_ACCESS_TOKEN_HEADERS_ID as string,
resourceAccessTokenHeadersToken: process.env
.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string
},
app: {
environment: process.env.ENVIRONMENT as string,
version: process.env.APP_VERSION as string,
dashboardUrl: process.env.DASHBOARD_URL as string,
dashboardUrl: process.env.DASHBOARD_URL as string
},
email: {
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false

View file

@ -3,11 +3,3 @@ export function constructShareLink(
) {
return `${window.location.origin}/s/${token!}`;
}
export function constructDirectShareLink(
param: string,
resourceUrl: string,
token: string
) {
return `${resourceUrl}?${param}=${token}`;
}

View file

@ -3,22 +3,24 @@ export type Env = {
environment: string;
version: string;
dashboardUrl: string;
},
};
server: {
externalPort: string;
nextPort: string;
sessionCookieName: string;
resourceAccessTokenParam: string;
resourceSessionRequestParam: string;
},
resourceAccessTokenHeadersId: string;
resourceAccessTokenHeadersToken: string;
};
email: {
emailEnabled: boolean;
},
};
flags: {
disableSignupWithoutInvite: boolean;
disableUserCreateOrg: boolean;
emailVerificationRequired: boolean;
allowRawResources: boolean;
allowBaseDomainResources: boolean;
}
};
};