Feat: Org Scoped JWT Tokens

This commit is contained in:
Daniel Hougaard
2024-03-09 09:01:13 +01:00
parent 8fc081973d
commit d287c3e152
8 changed files with 129 additions and 26 deletions

View File

@ -1,7 +1,10 @@
import jwt from "jsonwebtoken";
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { authRateLimit } from "@app/server/config/rateLimiter";
import { AuthModeJwtTokenPayload } from "@app/services/auth/auth-type";
export const registerLoginRouter = async (server: FastifyZodProvider) => {
server.route({
@ -34,6 +37,67 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "POST",
url: "/select-organization",
config: {
rateLimit: authRateLimit
},
schema: {
body: z.object({
organizationId: z.string().trim()
}),
response: {
200: z.object({
token: z.string()
})
}
},
handler: async (req, res) => {
const cfg = getConfig();
if (!req.headers.authorization) throw new UnauthorizedError({ name: "Authorization header is required" });
if (!req.headers["user-agent"]) throw new UnauthorizedError({ name: "user agent header is required" });
const userAgent = req.headers["user-agent"];
const authToken = req.headers.authorization.slice(7); // slice of after Bearer
// The decoded JWT token, which contains the auth method.
const decodedToken = jwt.verify(authToken, cfg.AUTH_SECRET) as AuthModeJwtTokenPayload;
if (decodedToken.organizationId) {
throw new UnauthorizedError({ message: "You have already selected an organization" });
}
const user = await server.services.user.getMe(decodedToken.userId);
// Check if the user actually has access to the specified organization.
const userOrgs = await server.services.org.findAllOrganizationOfUser(user.id);
if (!userOrgs.some((org) => org.id === req.body.organizationId)) {
throw new UnauthorizedError({ message: "User does not have access to the organization" });
}
await server.services.authToken.clearTokenSessionById(decodedToken.userId, decodedToken.tokenVersionId);
const tokens = await server.services.login.generateUserTokens({
authMethod: decodedToken.authMethod,
user,
userAgent,
ip: req.realIp,
organizationId: req.body.organizationId
});
void res.setCookie("jid", tokens.refresh, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: cfg.HTTPS_ENABLED
});
return { token: tokens.access };
}
});
server.route({
method: "POST",
url: "/login2",

View File

@ -6,7 +6,8 @@ import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { AuthModeJwtTokenPayload } from "../auth/auth-type";
import { AuthMethod, AuthModeJwtTokenPayload } from "../auth/auth-type";
import { TOrgDALFactory } from "../org/org-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TTokenDALFactory } from "./auth-token-dal";
import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types";
@ -14,6 +15,7 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo
type TAuthTokenServiceFactoryDep = {
tokenDAL: TTokenDALFactory;
userDAL: Pick<TUserDALFactory, "findById">;
orgDAL: TOrgDALFactory;
};
export type TAuthTokenServiceFactory = ReturnType<typeof tokenServiceFactory>;
@ -54,7 +56,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
}
};
export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFactoryDep) => {
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgDAL }: TAuthTokenServiceFactoryDep) => {
const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => {
const { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig();
@ -135,12 +137,25 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFact
id: token.tokenVersionId,
userId: token.userId
});
if (!session) throw new UnauthorizedError({ name: "Session not found" });
if (token.accessVersion !== session.accessVersion) throw new UnauthorizedError({ name: "Stale session" });
const user = await userDAL.findById(session.userId);
if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" });
if (token.organizationId) {
const organization = await orgDAL.findById(token.organizationId);
if (organization.authEnforced) {
const tokenAuthMode = token.authMethod;
if (![AuthMethod.AZURE_SAML, AuthMethod.OKTA_SAML, AuthMethod.JUMPCLOUD_SAML].includes(tokenAuthMode)) {
throw new UnauthorizedError({ name: "Organization enforces SAML" });
}
}
}
return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId };
};

View File

@ -15,10 +15,10 @@ export const validateProviderAuthToken = (providerToken: string, username?: stri
if (decodedToken.username !== username) throw new Error("Invalid auth credentials");
if (decodedToken.organizationId) {
return { orgId: decodedToken.organizationId };
return { orgId: decodedToken.organizationId, authMethod: decodedToken.authMethod };
}
return {};
return { authMethod: decodedToken.authMethod, orgId: null };
};
export const validateSignUpAuthorization = (token: string, userId: string, validate = true) => {

View File

@ -17,7 +17,7 @@ import {
TOauthLoginDTO,
TVerifyMfaTokenDTO
} from "./auth-login-type";
import { AuthMethod, AuthTokenType } from "./auth-type";
import { AuthMethod, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type";
type TAuthLoginServiceFactoryDep = {
userDAL: TUserDALFactory;
@ -83,12 +83,14 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
user,
ip,
userAgent,
organizationId
organizationId,
authMethod
}: {
user: TUsers;
ip: string;
userAgent: string;
organizationId?: string;
authMethod: AuthMethod;
}) => {
const cfg = getConfig();
await updateUserDeviceSession(user, ip, userAgent);
@ -98,8 +100,10 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
userId: user.id
});
if (!tokenSession) throw new Error("Failed to create token");
const accessToken = jwt.sign(
{
authMethod,
authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: user.id,
tokenVersionId: tokenSession.id,
@ -112,6 +116,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
const refreshToken = jwt.sign(
{
authMethod,
authTokenType: AuthTokenType.REFRESH_TOKEN,
userId: user.id,
tokenVersionId: tokenSession.id,
@ -158,9 +163,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
const loginExchangeClientProof = async ({
email,
clientProof,
providerAuthToken,
ip,
userAgent
userAgent,
providerAuthToken
}: TLoginClientProofDTO) => {
const userEnc = await userDAL.findUserEncKeyByUsername({
username: email
@ -168,14 +173,14 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
if (!userEnc) throw new Error("Failed to find user");
const cfg = getConfig();
let organizationId;
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
const { orgId } = validateProviderAuthToken(providerAuthToken as string, email);
organizationId = orgId;
} else if (providerAuthToken) {
// SAML SSO
const { orgId } = validateProviderAuthToken(providerAuthToken, email);
organizationId = orgId;
// let organizationId;
// let authMethod = (providerAuthToken as AuthMethod) || AuthMethod.EMAIL;
let authMethod = AuthMethod.EMAIL;
if (providerAuthToken) {
authMethod = validateProviderAuthToken(providerAuthToken, email).authMethod;
}
if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?");
@ -196,9 +201,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
if (userEnc.isMfaEnabled && userEnc.email) {
const mfaToken = jwt.sign(
{
authMethod,
authTokenType: AuthTokenType.MFA_TOKEN,
userId: userEnc.userId,
organizationId
userId: userEnc.userId
},
cfg.AUTH_SECRET,
{
@ -221,7 +226,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
},
ip,
userAgent,
organizationId
authMethod
});
return { token, isMfaEnabled: false, user: userEnc } as const;
@ -250,6 +255,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
userId,
code: mfaToken
});
const decodedToken = jwt.verify(mfaToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload;
const userEnc = await userDAL.findUserEncKeyByUserId(userId);
if (!userEnc) throw new Error("Failed to authenticate user");
@ -260,7 +268,8 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
},
ip,
userAgent,
organizationId: orgId
organizationId: orgId,
authMethod: decodedToken.authMethod
});
return { token, user: userEnc };

View File

@ -174,6 +174,7 @@ export const authSignupServiceFactory = ({
const accessToken = jwt.sign(
{
authMethod: AuthMethod.EMAIL,
authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: updateduser.info.id,
tokenVersionId: tokenSession.id,
@ -277,6 +278,7 @@ export const authSignupServiceFactory = ({
const accessToken = jwt.sign(
{
authMethod: AuthMethod.EMAIL,
authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: updateduser.info.id,
tokenVersionId: tokenSession.id,

View File

@ -40,6 +40,7 @@ export enum ActorType { // would extend to AWS, Azure, ...
export type AuthModeJwtTokenPayload = {
authTokenType: AuthTokenType.ACCESS_TOKEN;
authMethod: AuthMethod;
userId: string;
tokenVersionId: string;
accessVersion: number;
@ -48,12 +49,15 @@ export type AuthModeJwtTokenPayload = {
export type AuthModeMfaJwtTokenPayload = {
authTokenType: AuthTokenType.MFA_TOKEN;
authMethod: AuthMethod;
userId: string;
organizationId?: string;
};
export type AuthModeRefreshJwtTokenPayload = {
// authMode
authTokenType: AuthTokenType.REFRESH_TOKEN;
authMethod: AuthMethod;
userId: string;
tokenVersionId: string;
refreshVersion: number;
@ -63,6 +67,8 @@ export type AuthModeRefreshJwtTokenPayload = {
export type AuthModeProviderJwtTokenPayload = {
authTokenType: AuthTokenType.PROVIDER_TOKEN;
username: string;
authMethod: AuthMethod;
email: string;
organizationId?: string;
};

View File

@ -9,6 +9,7 @@ import { getConfig } from "@app/lib/config/env";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { ActorType } from "../auth/auth-type";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TServiceTokenDALFactory } from "./service-token-dal";
@ -24,6 +25,7 @@ type TServiceTokenServiceFactoryDep = {
userDAL: TUserDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findBySlugs">;
projectDAL: Pick<TProjectDALFactory, "findById">;
};
export type TServiceTokenServiceFactory = ReturnType<typeof serviceTokenServiceFactory>;
@ -32,7 +34,8 @@ export const serviceTokenServiceFactory = ({
serviceTokenDAL,
userDAL,
permissionService,
projectEnvDAL
projectEnvDAL,
projectDAL
}: TServiceTokenServiceFactoryDep) => {
const createServiceToken = async ({
iv,
@ -132,6 +135,9 @@ export const serviceTokenServiceFactory = ({
const serviceToken = await serviceTokenDAL.findById(TOKEN_IDENTIFIER);
if (!serviceToken) throw new UnauthorizedError();
const project = await projectDAL.findById(serviceToken.projectId);
if (!project) throw new UnauthorizedError({ message: "Service token project not found" });
if (serviceToken.expiresAt && new Date(serviceToken.expiresAt) < new Date()) {
await serviceTokenDAL.deleteById(serviceToken.id);
@ -144,7 +150,7 @@ export const serviceTokenServiceFactory = ({
lastUsed: new Date()
});
return { ...serviceToken, lastUsed: updatedToken.lastUsed };
return { ...serviceToken, lastUsed: updatedToken.lastUsed, orgId: project.orgId };
};
return {

View File

@ -13,13 +13,13 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
return (
<div
className={twMerge(
"container h-full mx-auto flex justify-center items-center",
"container mx-auto flex h-full items-center justify-center",
containerClassName
)}
>
<div
className={twMerge(
"rounded-md bg-mineshaft-800 text-bunker-300 p-16 flex space-x-12 items-end",
"flex items-end space-x-12 rounded-md bg-mineshaft-800 p-16 text-bunker-300",
className
)}
>
@ -27,10 +27,11 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
<FontAwesomeIcon icon={faLock} size="6x" />
</div>
<div>
<div className="text-4xl font-medium mb-2">Access Restricted</div>
<div className="mb-2 text-4xl font-medium">Access Restricted</div>
{children || (
<div className="text-sm">
Your role has limited permissions, please <br/> contact your administrator to gain access
Your role has limited permissions, please <br /> contact your administrator to gain
access
</div>
)}
</div>