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,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
};
}

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

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