refactor auth to work cross domain and with http resources closes #100

This commit is contained in:
Milo Schwartz 2025-01-26 14:42:02 -05:00
parent 6050a0a7d7
commit 9f1f2910e4
No known key found for this signature in database
27 changed files with 688 additions and 201 deletions

View file

@ -10,9 +10,9 @@ server:
next_port: 3002 next_port: 3002
internal_hostname: "pangolin" internal_hostname: "pangolin"
secure_cookies: true secure_cookies: true
session_cookie_name: "p_session" session_cookie_name: "p_session_token"
resource_session_cookie_name: "p_resource_session"
resource_access_token_param: "p_token" resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"
traefik: traefik:
cert_resolver: "letsencrypt" cert_resolver: "letsencrypt"

View file

@ -10,9 +10,9 @@ server:
next_port: 3002 next_port: 3002
internal_hostname: "pangolin" internal_hostname: "pangolin"
secure_cookies: true secure_cookies: true
session_cookie_name: "p_session" session_cookie_name: "p_session_token"
resource_session_cookie_name: "p_resource_session"
resource_access_token_param: "p_token" resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"
cors: cors:
origins: ["https://{{.DashboardDomain}}"] origins: ["https://{{.DashboardDomain}}"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]

View file

@ -64,6 +64,7 @@
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.1.3", "next": "15.1.3",
"next-themes": "0.4.4", "next-themes": "0.4.4",
"node-cache": "5.1.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.16", "nodemailer": "6.9.16",
"oslo": "1.2.1", "oslo": "1.2.1",

View file

@ -3,7 +3,13 @@ import {
encodeHexLowerCase encodeHexLowerCase
} from "@oslojs/encoding"; } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema"; import {
resourceSessions,
Session,
sessions,
User,
users
} from "@server/db/schema";
import db from "@server/db"; import db from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
@ -13,9 +19,14 @@ import logger from "@server/logger";
export const SESSION_COOKIE_NAME = export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name; config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; export const SESSION_COOKIE_EXPIRES =
1000 *
60 *
60 *
config.getRawConfig().server.dashboard_session_length_hours;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain(); export const COOKIE_DOMAIN =
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
export function generateSessionToken(): string { export function generateSessionToken(): string {
const bytes = new Uint8Array(20); const bytes = new Uint8Array(20);
@ -65,12 +76,21 @@ export async function validateSessionToken(
session.expiresAt = new Date( session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES Date.now() + SESSION_COOKIE_EXPIRES
).getTime(); ).getTime();
await db await db.transaction(async (trx) => {
.update(sessions) await trx
.set({ .update(sessions)
expiresAt: session.expiresAt .set({
}) expiresAt: session.expiresAt
.where(eq(sessions.sessionId, session.sessionId)); })
.where(eq(sessions.sessionId, session.sessionId));
await trx
.update(resourceSessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(resourceSessions.userSessionId, session.sessionId));
});
} }
return { session, user }; return { session, user };
} }
@ -90,9 +110,9 @@ export function serializeSessionCookie(
if (isSecure) { if (isSecure) {
logger.debug("Setting cookie for secure origin"); logger.debug("Setting cookie for secure origin");
if (SECURE_COOKIES) { if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else { } else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${COOKIE_DOMAIN}`;
} }
} else { } else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`; return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;

View file

@ -6,19 +6,20 @@ import { eq, and } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
export const SESSION_COOKIE_NAME = export const SESSION_COOKIE_NAME =
config.getRawConfig().server.resource_session_cookie_name; config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; export const SESSION_COOKIE_EXPIRES =
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export async function createResourceSession(opts: { export async function createResourceSession(opts: {
token: string; token: string;
resourceId: number; resourceId: number;
passwordId?: number; isRequestToken?: boolean;
pincodeId?: number; passwordId?: number | null;
whitelistId?: number; pincodeId?: number | null;
accessTokenId?: string; userSessionId?: string | null;
usedOtp?: boolean; whitelistId?: number | null;
accessTokenId?: string | null;
doNotExtend?: boolean; doNotExtend?: boolean;
expiresAt?: number | null; expiresAt?: number | null;
sessionLength?: number | null; sessionLength?: number | null;
@ -27,7 +28,8 @@ export async function createResourceSession(opts: {
!opts.passwordId && !opts.passwordId &&
!opts.pincodeId && !opts.pincodeId &&
!opts.whitelistId && !opts.whitelistId &&
!opts.accessTokenId !opts.accessTokenId &&
!opts.userSessionId
) { ) {
throw new Error("Auth method must be provided"); throw new Error("Auth method must be provided");
} }
@ -47,7 +49,9 @@ export async function createResourceSession(opts: {
pincodeId: opts.pincodeId || null, pincodeId: opts.pincodeId || null,
whitelistId: opts.whitelistId || null, whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false, doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null accessTokenId: opts.accessTokenId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null
}; };
await db.insert(resourceSessions).values(session); await db.insert(resourceSessions).values(session);
@ -162,22 +166,25 @@ export async function invalidateAllSessions(
export function serializeResourceSessionCookie( export function serializeResourceSessionCookie(
cookieName: string, cookieName: string,
token: string domain: string,
token: string,
isHttp: boolean = false
): string { ): string {
if (SECURE_COOKIES) { if (SECURE_COOKIES && !isHttp) {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
} else { } else {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
} }
} }
export function createBlankResourceSessionTokenCookie( export function createBlankResourceSessionTokenCookie(
cookieName: string cookieName: string,
domain: string
): string { ): string {
if (SECURE_COOKIES) { if (SECURE_COOKIES) {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
} else { } else {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
} }
} }

View file

@ -313,6 +313,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
doNotExtend: integer("doNotExtend", { mode: "boolean" }) doNotExtend: integer("doNotExtend", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
onDelete: "cascade"
}),
passwordId: integer("passwordId").references( passwordId: integer("passwordId").references(
() => resourcePassword.passwordId, () => resourcePassword.passwordId,
{ {

View file

@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer"; import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer"; import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer"; import { createInternalServer } from "./internalServer";
import { User, UserOrg } from "./db/schema"; import { Session, User, UserOrg } from "./db/schema";
async function startServers() { async function startServers() {
await runSetupFunctions(); await runSetupFunctions();
@ -24,6 +24,7 @@ declare global {
namespace Express { namespace Express {
interface Request { interface Request {
user?: User; user?: User;
session?: Session;
userOrg?: UserOrg; userOrg?: UserOrg;
userOrgRoleId?: number; userOrgRoleId?: number;
userOrgId?: string; userOrgId?: string;

View file

@ -61,8 +61,20 @@ const configSchema = z.object({
internal_hostname: z.string().transform((url) => url.toLowerCase()), internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(), secure_cookies: z.boolean(),
session_cookie_name: z.string(), session_cookie_name: z.string(),
resource_session_cookie_name: z.string(),
resource_access_token_param: z.string(), resource_access_token_param: z.string(),
resource_session_request_param: z.string(),
dashboard_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
resource_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
cors: z cors: z
.object({ .object({
origins: z.array(z.string()).optional(), origins: z.array(z.string()).optional(),
@ -241,8 +253,6 @@ export class Config {
: "false"; : "false";
process.env.SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME =
parsedConfig.data.server.session_cookie_name; parsedConfig.data.server.session_cookie_name;
process.env.RESOURCE_SESSION_COOKIE_NAME =
parsedConfig.data.server.resource_session_cookie_name;
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
?.disable_signup_without_invite ?.disable_signup_without_invite
@ -254,6 +264,8 @@ export class Config {
: "false"; : "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM = process.env.RESOURCE_ACCESS_TOKEN_PARAM =
parsedConfig.data.server.resource_access_token_param; parsedConfig.data.server.resource_access_token_param;
process.env.RESOURCE_SESSION_REQUEST_PARAM =
parsedConfig.data.server.resource_session_request_param;
this.rawConfig = parsedConfig.data; this.rawConfig = parsedConfig.data;
} }

View file

@ -5,18 +5,17 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
createBlankSessionTokenCookie, createBlankSessionTokenCookie,
invalidateSession, invalidateSession
SESSION_COOKIE_NAME
} from "@server/auth/sessions/app"; } from "@server/auth/sessions/app";
import { verifySession } from "@server/auth/sessions/verifySession";
export async function logout( export async function logout(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
const sessionId = req.cookies[SESSION_COOKIE_NAME]; const { user, session } = await verifySession(req);
if (!user || !session) {
if (!sessionId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
@ -26,7 +25,7 @@ export async function logout(
} }
try { try {
await invalidateSession(sessionId); await invalidateSession(session.sessionId);
const isSecure = req.protocol === "https"; const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));

View file

@ -0,0 +1,175 @@
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { resourceAccessToken, resources, sessions } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken } from "@server/auth";
import { SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { response } from "@server/lib";
const exchangeSessionBodySchema = z.object({
requestToken: z.string(),
host: z.string()
});
export type ExchangeSessionBodySchema = z.infer<
typeof exchangeSessionBodySchema
>;
export type ExchangeSessionResponse = {
valid: boolean;
cookie?: string;
};
export async function exchangeSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
logger.debug("Exchange session: Badger sent", req.body);
const parsedBody = exchangeSessionBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
try {
const { requestToken, host } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with host ${host} not found`
)
);
}
const { resourceSession: requestSession } =
await validateResourceSessionToken(
requestToken,
resource.resourceId
);
if (!requestSession) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
if (!requestSession.isRequestToken) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
await db.delete(sessions).where(eq(sessions.sessionId, requestToken));
const token = generateSessionToken();
if (requestSession.userSessionId) {
const [res] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionId, requestSession.userSessionId))
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
userSessionId: requestSession.userSessionId,
doNotExtend: false,
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
}
} else if (requestSession.accessTokenId) {
const [res] = await db
.select()
.from(resourceAccessToken)
.where(
eq(
resourceAccessToken.accessTokenId,
requestSession.accessTokenId
)
)
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
accessTokenId: requestSession.accessTokenId,
doNotExtend: true,
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
}
} else {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
passwordId: requestSession.passwordId,
pincodeId: requestSession.pincodeId,
userSessionId: requestSession.userSessionId,
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
expiresAt: new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime(),
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain,
token,
!resource.ssl
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
return response<ExchangeSessionResponse>(res, {
data: { valid: true, cookie },
success: true,
error: false,
message: "Session exchanged successfully",
status: HttpCode.OK
});
} catch (e) {
console.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to exchange session"
)
);
}
}

View file

@ -1 +1,2 @@
export * from "./verifySession"; export * from "./verifySession";
export * from "./exchangeSession";

View file

@ -4,17 +4,17 @@ import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { validateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db"; import db from "@server/db";
import { import {
ResourceAccessToken, ResourceAccessToken,
resourceAccessToken, ResourcePassword,
resourcePassword, resourcePassword,
ResourcePincode,
resourcePincode, resourcePincode,
resources, resources,
resourceWhitelist, sessions,
User, userOrgs,
userOrgs users
} from "@server/db/schema"; } from "@server/db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
@ -27,6 +27,12 @@ import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth"; import { generateSessionToken } from "@server/auth";
import NodeCache from "node-cache";
// We'll see if this speeds anything up
const cache = new NodeCache({
stdTTL: 5 // seconds
});
const verifyResourceSessionSchema = z.object({ const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(), sessions: z.record(z.string()).optional(),
@ -53,7 +59,7 @@ export async function verifyResourceSession(
res: Response, res: Response,
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
logger.debug("Badger sent", req.body); // remove when done testing logger.debug("Verify session: Badger sent", req.body); // remove when done testing
const parsedBody = verifyResourceSessionSchema.safeParse(req.body); const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
@ -67,26 +73,52 @@ export async function verifyResourceSession(
} }
try { try {
const { sessions, host, originalRequestURL, accessToken: token } = const {
parsedBody.data; sessions,
host,
originalRequestURL,
accessToken: token
} = parsedBody.data;
const [result] = await db const resourceCacheKey = `resource:${host}`;
.select() let resourceData:
.from(resources) | {
.leftJoin( resource: Resource | null;
resourcePincode, pincode: ResourcePincode | null;
eq(resourcePincode.resourceId, resources.resourceId) password: ResourcePassword | null;
) }
.leftJoin( | undefined = cache.get(resourceCacheKey);
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
const resource = result?.resources; if (!resourceData) {
const pincode = result?.resourcePincode; const [result] = await db
const password = result?.resourcePassword; .select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!result) {
logger.debug("Resource not found", host);
return notAllowed(res);
}
resourceData = {
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword
};
cache.set(resourceCacheKey, resourceData);
}
const { resource, pincode, password } = resourceData;
if (!resource) { if (!resource) {
logger.debug("Resource not found", host); logger.debug("Resource not found", host);
@ -145,37 +177,31 @@ export async function verifyResourceSession(
return notAllowed(res); return notAllowed(res);
} }
const sessionToken =
sessions[config.getRawConfig().server.session_cookie_name];
// check for unified login
if (sso && sessionToken) {
const { session, user } = await validateSessionToken(sessionToken);
if (session && user) {
const isAllowed = await isUserAllowedToAccessResource(
user,
resource
);
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
);
return allowed(res);
}
}
}
const resourceSessionToken = const resourceSessionToken =
sessions[ sessions[
`${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}` `${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
]; ];
if (resourceSessionToken) { if (resourceSessionToken) {
const { resourceSession } = await validateResourceSessionToken( const sessionCacheKey = `session:${resourceSessionToken}`;
resourceSessionToken, let resourceSession: any = cache.get(sessionCacheKey);
resource.resourceId
); if (!resourceSession) {
const result = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
resourceSession = result?.resourceSession;
cache.set(sessionCacheKey, resourceSession);
}
if (resourceSession?.isRequestToken) {
logger.debug(
"Resource not allowed because session is a temporary request token"
);
return notAllowed(res);
}
if (resourceSession) { if (resourceSession) {
if (pincode && resourceSession.pincodeId) { if (pincode && resourceSession.pincodeId) {
@ -208,6 +234,29 @@ export async function verifyResourceSession(
); );
return allowed(res); return allowed(res);
} }
if (resourceSession.userSessionId && sso) {
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
let isAllowed: boolean | undefined =
cache.get(userAccessCacheKey);
if (isAllowed === undefined) {
isAllowed = await isUserAllowedToAccessResource(
resourceSession.userSessionId,
resource
);
cache.set(userAccessCacheKey, isAllowed);
}
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
);
return allowed(res);
}
}
} }
} }
@ -272,10 +321,15 @@ async function createAccessTokenSession(
expiresAt: tokenItem.expiresAt, expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false doNotExtend: tokenItem.expiresAt ? true : false
}); });
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(cookieName, token); const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain,
token,
!resource.ssl
);
res.appendHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session") logger.debug("Access token is valid, creating new session");
return response<VerifyUserResponse>(res, { return response<VerifyUserResponse>(res, {
data: { valid: true }, data: { valid: true },
success: true, success: true,
@ -286,9 +340,22 @@ async function createAccessTokenSession(
} }
async function isUserAllowedToAccessResource( async function isUserAllowedToAccessResource(
user: User, userSessionId: string,
resource: Resource resource: Resource
): Promise<boolean> { ): Promise<boolean> {
const [res] = await db
.select()
.from(sessions)
.leftJoin(users, eq(users.userId, sessions.userId))
.where(eq(sessions.sessionId, userSessionId));
const user = res.user;
const session = res.session;
if (!user || !session) {
return false;
}
if ( if (
config.getRawConfig().flags?.require_email_verification && config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified !user.emailVerified

View file

@ -3,7 +3,9 @@ import * as gerbil from "@server/routers/gerbil";
import * as badger from "@server/routers/badger"; import * as badger from "@server/routers/badger";
import * as traefik from "@server/routers/traefik"; import * as traefik from "@server/routers/traefik";
import * as auth from "@server/routers/auth"; import * as auth from "@server/routers/auth";
import * as resource from "@server/routers/resource";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
// Root routes // Root routes
const internalRouter = Router(); const internalRouter = Router();
@ -15,7 +17,14 @@ internalRouter.get("/", (_, res) => {
internalRouter.get("/traefik-config", traefik.traefikConfigProvider); internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
internalRouter.get( internalRouter.get(
"/resource-session/:resourceId/:token", "/resource-session/:resourceId/:token",
auth.checkResourceSession, auth.checkResourceSession
);
internalRouter.post(
`/resource/:resourceId/get-exchange-token`,
verifySessionUserMiddleware,
verifyResourceAccess,
resource.getExchangeToken
); );
// Gerbil routes // Gerbil routes
@ -30,5 +39,6 @@ const badgerRouter = Router();
internalRouter.use("/badger", badgerRouter); internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
export default internalRouter; export default internalRouter;

View file

@ -1,18 +1,16 @@
import { generateSessionToken } from "@server/auth/sessions/app"; import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db"; import db from "@server/db";
import { resourceAccessToken, resources } from "@server/db/schema"; import { resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
createResourceSession, createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
@ -108,13 +106,11 @@ export async function authWithAccessToken(
resourceId, resourceId,
token, token,
accessTokenId: tokenItem.accessTokenId, accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength, isRequestToken: true,
expiresAt: tokenItem.expiresAt, expiresAt: Date.now() + 1000 * 30, // 30 seconds
doNotExtend: tokenItem.expiresAt ? true : false sessionLength: 1000 * 30,
doNotExtend: true
}); });
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithAccessTokenResponse>(res, { return response<AuthWithAccessTokenResponse>(res, {
data: { data: {

View file

@ -11,9 +11,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
createResourceSession, createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
@ -120,11 +118,12 @@ export async function authWithPassword(
await createResourceSession({ await createResourceSession({
resourceId, resourceId,
token, token,
passwordId: definedPassword.passwordId passwordId: definedPassword.passwordId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
}); });
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPasswordResponse>(res, { return response<AuthWithPasswordResponse>(res, {
data: { data: {

View file

@ -1,28 +1,21 @@
import { verify } from "@node-rs/argon2";
import { generateSessionToken } from "@server/auth/sessions/app"; import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db"; import db from "@server/db";
import { import {
orgs, orgs,
resourceOtp,
resourcePincode, resourcePincode,
resources, resources,
resourceWhitelist
} from "@server/db/schema"; } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { and, eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
createResourceSession, createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config";
import { AuthWithPasswordResponse } from "./authWithPassword";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
export const authWithPincodeBodySchema = z export const authWithPincodeBodySchema = z
@ -128,11 +121,12 @@ export async function authWithPincode(
await createResourceSession({ await createResourceSession({
resourceId, resourceId,
token, token,
pincodeId: definedPincode.pincodeId pincodeId: definedPincode.pincodeId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
}); });
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPincodeResponse>(res, { return response<AuthWithPincodeResponse>(res, {
data: { data: {

View file

@ -3,7 +3,6 @@ import db from "@server/db";
import { import {
orgs, orgs,
resourceOtp, resourceOtp,
resourcePassword,
resources, resources,
resourceWhitelist resourceWhitelist
} from "@server/db/schema"; } from "@server/db/schema";
@ -16,9 +15,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
createResourceSession, createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import logger from "@server/logger"; import logger from "@server/logger";
@ -178,11 +175,12 @@ export async function authWithWhitelist(
await createResourceSession({ await createResourceSession({
resourceId, resourceId,
token, token,
whitelistId: whitelistedEmail.whitelistId whitelistId: whitelistedEmail.whitelistId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
}); });
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithWhitelistResponse>(res, { return response<AuthWithWhitelistResponse>(res, {
data: { data: {

View file

@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { createResourceSession } from "@server/auth/sessions/resource";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { generateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib";
const getExchangeTokenParams = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export type GetExchangeTokenResponse = {
requestToken: string;
};
export async function getExchangeToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getExchangeTokenParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const resource = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (resource.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const ssoSession =
req.cookies[config.getRawConfig().server.session_cookie_name];
if (!ssoSession) {
logger.debug(ssoSession);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Missing SSO session cookie"
)
);
}
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(ssoSession))
);
const token = generateSessionToken();
await createResourceSession({
resourceId,
token,
userSessionId: sessionId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
logger.debug("Request token created successfully");
return response<GetExchangeTokenResponse>(res, {
data: {
requestToken: token
},
success: true,
error: false,
message: "Request token created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -16,3 +16,4 @@ export * from "./setResourceWhitelist";
export * from "./getResourceWhitelist"; export * from "./getResourceWhitelist";
export * from "./authWithWhitelist"; export * from "./authWithWhitelist";
export * from "./authWithAccessToken"; export * from "./authWithAccessToken";
export * from "./getExchangeToken";

View file

@ -111,6 +111,10 @@ export async function updateResource(
); );
} }
if (resource[0].resources.ssl !== updatedResource[0].ssl) {
// invalidate all sessions?
}
return response(res, { return response(res, {
data: updatedResource[0], data: updatedResource[0],
success: true, success: true,

View file

@ -39,7 +39,7 @@ export async function traefikConfigProvider(
} }
const badgerMiddlewareName = "badger"; const badgerMiddlewareName = "badger";
const redirectMiddlewareName = "redirect-to-https"; const redirectHttpsMiddlewareName = "redirect-to-https";
const http: any = { const http: any = {
routers: {}, routers: {},
@ -52,19 +52,18 @@ export async function traefikConfigProvider(
"/api/v1", "/api/v1",
`http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`, `http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`,
).href, ).href,
resourceSessionCookieName:
config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName: userSessionCookieName:
config.getRawConfig().server.session_cookie_name, config.getRawConfig().server.session_cookie_name,
accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param, accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
resourceSessionRequestParam: config.getRawConfig().server.resource_session_request_param
}, },
}, },
}, },
[redirectMiddlewareName]: { [redirectHttpsMiddlewareName]: {
redirectScheme: { redirectScheme: {
scheme: "https" scheme: "https"
}, },
}, }
}, },
}; };
for (const item of all) { for (const item of all) {
@ -120,10 +119,9 @@ export async function traefikConfigProvider(
}; };
if (resource.ssl) { if (resource.ssl) {
// this is a redirect router; all it does is redirect to the https version if tls is enabled
http.routers![routerName + "-redirect"] = { http.routers![routerName + "-redirect"] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint], entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectMiddlewareName], middlewares: [redirectHttpsMiddlewareName],
service: serviceName, service: serviceName,
rule: `Host(\`${fullDomain}\`)`, rule: `Host(\`${fullDomain}\`)`,
}; };

View file

@ -7,7 +7,13 @@ import {
userInvites, userInvites,
users users
} from "@server/db/schema"; } from "@server/db/schema";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
export default async function migration() { export default async function migration() {
console.log("Running setup script 1.0.0-beta.9..."); console.log("Running setup script 1.0.0-beta.9...");
@ -32,5 +38,96 @@ export default async function migration() {
console.error(error); console.error(error);
} }
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);
rawConfig.server.resource_session_request_param = "p_session_request";
rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts
delete rawConfig.server.resource_session_cookie_name;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (e) {
console.log(
`Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config`
);
throw e;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.0.0-beta.3";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.0.0-beta.3."
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
);
console.error(e);
}
try {
await db.transaction(async (trx) => {
trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`);
trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`);
});
} catch (e) {
console.log(
"We were unable to add columns to the resourceSessions table."
);
throw e;
}
console.log("Done."); console.log("Done.");
} }

View file

@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardFooter,
CardHeader, CardHeader,
CardTitle CardTitle
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { AuthWithAccessTokenResponse } from "@server/routers/resource"; import { AuthWithAccessTokenResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { Loader2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -32,7 +30,17 @@ export default function AccessToken({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false); const [isValid, setIsValid] = useState(false);
const api = createApiClient(useEnvContext()); const { env } = useEnvContext();
const api = createApiClient({ env });
function appendRequestToken(url: string, token: string) {
const fullUrl = new URL(url);
fullUrl.searchParams.append(
env.server.resourceSessionRequestParam,
token
);
return fullUrl.toString();
}
useEffect(() => { useEffect(() => {
if (!accessTokenId || !accessToken) { if (!accessTokenId || !accessToken) {
@ -51,7 +59,10 @@ export default function AccessToken({
if (res.data.data.session) { if (res.data.data.session) {
setIsValid(true); setIsValid(true);
window.location.href = redirectUrl; window.location.href = appendRequestToken(
redirectUrl,
res.data.data.session
);
} }
} catch (e) { } catch (e) {
console.error("Error checking access token", e); console.error("Error checking access token", e);

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useSyncExternalStore } from "react"; import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
@ -8,7 +8,6 @@ import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter,
CardHeader, CardHeader,
CardTitle CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
@ -30,9 +29,6 @@ import {
Key, Key,
User, User,
Send, Send,
ArrowLeft,
ArrowRight,
Lock,
AtSign AtSign
} from "lucide-react"; } from "lucide-react";
import { import {
@ -47,10 +43,8 @@ import { AxiosResponse } from "axios";
import LoginForm from "@app/components/LoginForm"; import LoginForm from "@app/components/LoginForm";
import { import {
AuthWithPasswordResponse, AuthWithPasswordResponse,
AuthWithAccessTokenResponse,
AuthWithWhitelistResponse AuthWithWhitelistResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import { redirect } from "next/dist/server/api-utils";
import ResourceAccessDenied from "./ResourceAccessDenied"; import ResourceAccessDenied from "./ResourceAccessDenied";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle"); const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
const api = createApiClient(useEnvContext()); const { env } = useEnvContext();
const api = createApiClient({ env });
function getDefaultSelectedMethod() { function getDefaultSelectedMethod() {
if (props.methods.sso) { if (props.methods.sso) {
@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
} }
}); });
function appendRequestToken(url: string, token: string) {
const fullUrl = new URL(url);
fullUrl.searchParams.append(
env.server.resourceSessionRequestParam,
token
);
return fullUrl.toString();
}
const onWhitelistSubmit = (values: any) => { const onWhitelistSubmit = (values: any) => {
setLoadingLogin(true); setLoadingLogin(true);
api.post<AxiosResponse<AuthWithWhitelistResponse>>( api.post<AxiosResponse<AuthWithWhitelistResponse>>(
@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = props.redirect; window.location.href = appendRequestToken(props.redirect, session);
} }
}) })
.catch((e) => { .catch((e) => {
@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPincodeError(null); setPincodeError(null);
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = props.redirect; window.location.href = appendRequestToken(props.redirect, session);
} }
}) })
.catch((e) => { .catch((e) => {
@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPasswordError(null); setPasswordError(null);
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = props.redirect; window.location.href = appendRequestToken(props.redirect, session);
} }
}) })
.catch((e) => { .catch((e) => {
@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>
{/* {activeTab === "sso" && (
<div className="flex justify-center mt-4">
<p className="text-sm text-muted-foreground">
Don't have an account?{" "}
<a href="#" className="underline">
Sign up
</a>
</p>
</div>
)} */}
</div> </div>
) : ( ) : (
<ResourceAccessDenied /> <ResourceAccessDenied />

View file

@ -1,7 +1,6 @@
import { import {
AuthWithAccessTokenResponse,
GetResourceAuthInfoResponse, GetResourceAuthInfoResponse,
GetResourceResponse GetExchangeTokenResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import ResourceAuthPortal from "./ResourceAuthPortal"; import ResourceAuthPortal from "./ResourceAuthPortal";
import { internal, priv } from "@app/lib/api"; import { internal, priv } from "@app/lib/api";
@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import ResourceNotFound from "./ResourceNotFound"; import ResourceNotFound from "./ResourceNotFound";
import ResourceAccessDenied from "./ResourceAccessDenied"; import ResourceAccessDenied from "./ResourceAccessDenied";
import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
import AccessTokenInvalid from "./AccessToken";
import AccessToken from "./AccessToken"; import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
@ -48,7 +44,7 @@ export default async function ResourceAuthPage(props: {
// TODO: fix this // TODO: fix this
return ( return (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* @ts-ignore */} {/* @ts-ignore */}
<ResourceNotFound /> <ResourceNotFound />
</div> </div>
); );
@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: {
); );
} }
const allCookies = await cookies();
const cookieName =
env.server.resourceSessionCookieName + `_${params.resourceId}`;
const sessionId = allCookies.get(cookieName)?.value ?? null;
if (sessionId) {
let doRedirect = false;
try {
const res = await priv.get<
AxiosResponse<CheckResourceSessionResponse>
>(`/resource-session/${params.resourceId}/${sessionId}`);
if (res && res.data.data.valid) {
doRedirect = true;
}
} catch (e) {}
if (doRedirect) {
redirect(redirectUrl);
}
}
if (!hasAuth) { if (!hasAuth) {
// no authentication so always go straight to the resource // no authentication so always go straight to the resource
redirect(redirectUrl); redirect(redirectUrl);
} }
// convert the dashboard token into a resource session token
let userIsUnauthorized = false; let userIsUnauthorized = false;
if (user && authInfo.sso) { if (user && authInfo.sso) {
let doRedirect = false; let redirectToUrl: string | undefined;
try { try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>( const res = await priv.post<
`/resource/${params.resourceId}`, AxiosResponse<GetExchangeTokenResponse>
>(
`/resource/${params.resourceId}/get-exchange-token`,
{},
await authCookieHeader() await authCookieHeader()
); );
doRedirect = true; if (res.data.data.requestToken) {
const paramName = env.server.resourceSessionRequestParam;
// append the param with the token to the redirect url
const fullUrl = new URL(redirectUrl);
fullUrl.searchParams.append(
paramName,
res.data.data.requestToken
);
redirectToUrl = fullUrl.toString();
}
} catch (e) { } catch (e) {
userIsUnauthorized = true; userIsUnauthorized = true;
} }
if (doRedirect) { if (redirectToUrl) {
redirect(redirectUrl); redirect(redirectToUrl);
} }
} }

View file

@ -6,8 +6,8 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string, nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string, resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string,
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string
}, },
app: { app: {
environment: process.env.ENVIRONMENT as string, environment: process.env.ENVIRONMENT as string,

View file

@ -7,8 +7,8 @@ export type Env = {
externalPort: string; externalPort: string;
nextPort: string; nextPort: string;
sessionCookieName: string; sessionCookieName: string;
resourceSessionCookieName: string;
resourceAccessTokenParam: string; resourceAccessTokenParam: string;
resourceSessionRequestParam: string;
}, },
email: { email: {
emailEnabled: boolean; emailEnabled: boolean;