Feat: Org Scoped JWT Tokens

This commit is contained in:
Daniel Hougaard
2024-03-17 18:48:10 +01:00
parent 8fc081973d
commit d287c3e152
8 changed files with 129 additions and 26 deletions
@@ -1,7 +1,10 @@
import jwt from "jsonwebtoken";
import { z } from "zod"; import { z } from "zod";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { authRateLimit } from "@app/server/config/rateLimiter"; import { authRateLimit } from "@app/server/config/rateLimiter";
import { AuthModeJwtTokenPayload } from "@app/services/auth/auth-type";
export const registerLoginRouter = async (server: FastifyZodProvider) => { export const registerLoginRouter = async (server: FastifyZodProvider) => {
server.route({ 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({ server.route({
method: "POST", method: "POST",
url: "/login2", url: "/login2",
@@ -6,7 +6,8 @@ import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors"; 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 { TUserDALFactory } from "../user/user-dal";
import { TTokenDALFactory } from "./auth-token-dal"; import { TTokenDALFactory } from "./auth-token-dal";
import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types"; import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types";
@@ -14,6 +15,7 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo
type TAuthTokenServiceFactoryDep = { type TAuthTokenServiceFactoryDep = {
tokenDAL: TTokenDALFactory; tokenDAL: TTokenDALFactory;
userDAL: Pick<TUserDALFactory, "findById">; userDAL: Pick<TUserDALFactory, "findById">;
orgDAL: TOrgDALFactory;
}; };
export type TAuthTokenServiceFactory = ReturnType<typeof tokenServiceFactory>; 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 createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => {
const { token, ...tkCfg } = getTokenConfig(type); const { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig(); const appCfg = getConfig();
@@ -135,12 +137,25 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFact
id: token.tokenVersionId, id: token.tokenVersionId,
userId: token.userId userId: token.userId
}); });
if (!session) throw new UnauthorizedError({ name: "Session not found" }); if (!session) throw new UnauthorizedError({ name: "Session not found" });
if (token.accessVersion !== session.accessVersion) throw new UnauthorizedError({ name: "Stale session" }); if (token.accessVersion !== session.accessVersion) throw new UnauthorizedError({ name: "Stale session" });
const user = await userDAL.findById(session.userId); const user = await userDAL.findById(session.userId);
if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" }); 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 }; return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId };
}; };
+2 -2
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.username !== username) throw new Error("Invalid auth credentials");
if (decodedToken.organizationId) { 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) => { export const validateSignUpAuthorization = (token: string, userId: string, validate = true) => {
+25 -16
View File
@@ -17,7 +17,7 @@ import {
TOauthLoginDTO, TOauthLoginDTO,
TVerifyMfaTokenDTO TVerifyMfaTokenDTO
} from "./auth-login-type"; } from "./auth-login-type";
import { AuthMethod, AuthTokenType } from "./auth-type"; import { AuthMethod, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type";
type TAuthLoginServiceFactoryDep = { type TAuthLoginServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
@@ -83,12 +83,14 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
user, user,
ip, ip,
userAgent, userAgent,
organizationId organizationId,
authMethod
}: { }: {
user: TUsers; user: TUsers;
ip: string; ip: string;
userAgent: string; userAgent: string;
organizationId?: string; organizationId?: string;
authMethod: AuthMethod;
}) => { }) => {
const cfg = getConfig(); const cfg = getConfig();
await updateUserDeviceSession(user, ip, userAgent); await updateUserDeviceSession(user, ip, userAgent);
@@ -98,8 +100,10 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
userId: user.id userId: user.id
}); });
if (!tokenSession) throw new Error("Failed to create token"); if (!tokenSession) throw new Error("Failed to create token");
const accessToken = jwt.sign( const accessToken = jwt.sign(
{ {
authMethod,
authTokenType: AuthTokenType.ACCESS_TOKEN, authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: user.id, userId: user.id,
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
@@ -112,6 +116,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ {
authMethod,
authTokenType: AuthTokenType.REFRESH_TOKEN, authTokenType: AuthTokenType.REFRESH_TOKEN,
userId: user.id, userId: user.id,
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
@@ -158,9 +163,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
const loginExchangeClientProof = async ({ const loginExchangeClientProof = async ({
email, email,
clientProof, clientProof,
providerAuthToken,
ip, ip,
userAgent userAgent,
providerAuthToken
}: TLoginClientProofDTO) => { }: TLoginClientProofDTO) => {
const userEnc = await userDAL.findUserEncKeyByUsername({ const userEnc = await userDAL.findUserEncKeyByUsername({
username: email username: email
@@ -168,14 +173,14 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
if (!userEnc) throw new Error("Failed to find user"); if (!userEnc) throw new Error("Failed to find user");
const cfg = getConfig(); const cfg = getConfig();
let organizationId; // let organizationId;
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
const { orgId } = validateProviderAuthToken(providerAuthToken as string, email); // let authMethod = (providerAuthToken as AuthMethod) || AuthMethod.EMAIL;
organizationId = orgId;
} else if (providerAuthToken) { let authMethod = AuthMethod.EMAIL;
// SAML SSO
const { orgId } = validateProviderAuthToken(providerAuthToken, email); if (providerAuthToken) {
organizationId = orgId; authMethod = validateProviderAuthToken(providerAuthToken, email).authMethod;
} }
if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); 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) { if (userEnc.isMfaEnabled && userEnc.email) {
const mfaToken = jwt.sign( const mfaToken = jwt.sign(
{ {
authMethod,
authTokenType: AuthTokenType.MFA_TOKEN, authTokenType: AuthTokenType.MFA_TOKEN,
userId: userEnc.userId, userId: userEnc.userId
organizationId
}, },
cfg.AUTH_SECRET, cfg.AUTH_SECRET,
{ {
@@ -221,7 +226,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
}, },
ip, ip,
userAgent, userAgent,
organizationId authMethod
}); });
return { token, isMfaEnabled: false, user: userEnc } as const; return { token, isMfaEnabled: false, user: userEnc } as const;
@@ -250,6 +255,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
userId, userId,
code: mfaToken code: mfaToken
}); });
const decodedToken = jwt.verify(mfaToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload;
const userEnc = await userDAL.findUserEncKeyByUserId(userId); const userEnc = await userDAL.findUserEncKeyByUserId(userId);
if (!userEnc) throw new Error("Failed to authenticate user"); if (!userEnc) throw new Error("Failed to authenticate user");
@@ -260,7 +268,8 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
}, },
ip, ip,
userAgent, userAgent,
organizationId: orgId organizationId: orgId,
authMethod: decodedToken.authMethod
}); });
return { token, user: userEnc }; return { token, user: userEnc };
@@ -174,6 +174,7 @@ export const authSignupServiceFactory = ({
const accessToken = jwt.sign( const accessToken = jwt.sign(
{ {
authMethod: AuthMethod.EMAIL,
authTokenType: AuthTokenType.ACCESS_TOKEN, authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: updateduser.info.id, userId: updateduser.info.id,
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
@@ -277,6 +278,7 @@ export const authSignupServiceFactory = ({
const accessToken = jwt.sign( const accessToken = jwt.sign(
{ {
authMethod: AuthMethod.EMAIL,
authTokenType: AuthTokenType.ACCESS_TOKEN, authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: updateduser.info.id, userId: updateduser.info.id,
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
+6
View File
@@ -40,6 +40,7 @@ export enum ActorType { // would extend to AWS, Azure, ...
export type AuthModeJwtTokenPayload = { export type AuthModeJwtTokenPayload = {
authTokenType: AuthTokenType.ACCESS_TOKEN; authTokenType: AuthTokenType.ACCESS_TOKEN;
authMethod: AuthMethod;
userId: string; userId: string;
tokenVersionId: string; tokenVersionId: string;
accessVersion: number; accessVersion: number;
@@ -48,12 +49,15 @@ export type AuthModeJwtTokenPayload = {
export type AuthModeMfaJwtTokenPayload = { export type AuthModeMfaJwtTokenPayload = {
authTokenType: AuthTokenType.MFA_TOKEN; authTokenType: AuthTokenType.MFA_TOKEN;
authMethod: AuthMethod;
userId: string; userId: string;
organizationId?: string; organizationId?: string;
}; };
export type AuthModeRefreshJwtTokenPayload = { export type AuthModeRefreshJwtTokenPayload = {
// authMode
authTokenType: AuthTokenType.REFRESH_TOKEN; authTokenType: AuthTokenType.REFRESH_TOKEN;
authMethod: AuthMethod;
userId: string; userId: string;
tokenVersionId: string; tokenVersionId: string;
refreshVersion: number; refreshVersion: number;
@@ -63,6 +67,8 @@ export type AuthModeRefreshJwtTokenPayload = {
export type AuthModeProviderJwtTokenPayload = { export type AuthModeProviderJwtTokenPayload = {
authTokenType: AuthTokenType.PROVIDER_TOKEN; authTokenType: AuthTokenType.PROVIDER_TOKEN;
username: string; username: string;
authMethod: AuthMethod;
email: string;
organizationId?: string; organizationId?: string;
}; };
@@ -9,6 +9,7 @@ import { getConfig } from "@app/lib/config/env";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TServiceTokenDALFactory } from "./service-token-dal"; import { TServiceTokenDALFactory } from "./service-token-dal";
@@ -24,6 +25,7 @@ type TServiceTokenServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findBySlugs">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findBySlugs">;
projectDAL: Pick<TProjectDALFactory, "findById">;
}; };
export type TServiceTokenServiceFactory = ReturnType<typeof serviceTokenServiceFactory>; export type TServiceTokenServiceFactory = ReturnType<typeof serviceTokenServiceFactory>;
@@ -32,7 +34,8 @@ export const serviceTokenServiceFactory = ({
serviceTokenDAL, serviceTokenDAL,
userDAL, userDAL,
permissionService, permissionService,
projectEnvDAL projectEnvDAL,
projectDAL
}: TServiceTokenServiceFactoryDep) => { }: TServiceTokenServiceFactoryDep) => {
const createServiceToken = async ({ const createServiceToken = async ({
iv, iv,
@@ -132,6 +135,9 @@ export const serviceTokenServiceFactory = ({
const serviceToken = await serviceTokenDAL.findById(TOKEN_IDENTIFIER); const serviceToken = await serviceTokenDAL.findById(TOKEN_IDENTIFIER);
if (!serviceToken) throw new UnauthorizedError(); 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()) { if (serviceToken.expiresAt && new Date(serviceToken.expiresAt) < new Date()) {
await serviceTokenDAL.deleteById(serviceToken.id); await serviceTokenDAL.deleteById(serviceToken.id);
@@ -144,7 +150,7 @@ export const serviceTokenServiceFactory = ({
lastUsed: new Date() lastUsed: new Date()
}); });
return { ...serviceToken, lastUsed: updatedToken.lastUsed }; return { ...serviceToken, lastUsed: updatedToken.lastUsed, orgId: project.orgId };
}; };
return { return {
@@ -13,13 +13,13 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
return ( return (
<div <div
className={twMerge( className={twMerge(
"container h-full mx-auto flex justify-center items-center", "container mx-auto flex h-full items-center justify-center",
containerClassName containerClassName
)} )}
> >
<div <div
className={twMerge( 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 className
)} )}
> >
@@ -27,10 +27,11 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
<FontAwesomeIcon icon={faLock} size="6x" /> <FontAwesomeIcon icon={faLock} size="6x" />
</div> </div>
<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 || ( {children || (
<div className="text-sm"> <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>
)} )}
</div> </div>