Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 96 additions & 49 deletions api/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import {
} from "../utils/custom-errors.utils.js";
import AuthenticationModel from "../models/authentication.js";
import logger from "../utils/logger.js";
import path from "path";
import fs from "fs";
import axios from "axios";
import { getAppOrganization, getAppOrganizationUID } from "../utils/auth.utils.js";
import { getAppJsonPath } from "../utils/app-config-path.utils.js";
import { decryptAppConfig } from "../utils/crypto.utils.js";
import { normalizeContentstackAuthorizeUrl } from "../utils/contentstack-oauth-url.utils.js";

Expand Down Expand Up @@ -232,7 +232,7 @@ const requestSms = async (req: Request): Promise<LoginServiceType> => {
};

const getAppConfig = () => {
const configPath = path.resolve(process.cwd(), '..', 'app.json');
const configPath = getAppJsonPath();
if (!fs.existsSync(configPath)) {
throw new InternalServerError("SSO is not configured. Please run the setup script first.");
}
Expand All @@ -253,7 +253,6 @@ const saveOAuthToken = async (req: Request): Promise<LoginServiceType> => {
}

try {
// Exchange the code for access token
const appConfig = getAppConfig();
const { client_id, client_secret, redirect_uri } = appConfig?.oauthData;
const { code_verifier } = appConfig?.pkce;
Expand All @@ -271,68 +270,104 @@ const saveOAuthToken = async (req: Request): Promise<LoginServiceType> => {
formData.append('redirect_uri', redirect_uri);
formData.append('code', code as string);
formData.append('code_verifier', code_verifier);

const tokenResponse = await https({
method: "POST",
url: tokenUrl,
data: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
method: "POST",
url: tokenUrl,
data: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});

const { access_token, refresh_token, organization_uid } = tokenResponse.data;
const { access_token, refresh_token, organization_uid } = tokenResponse?.data;

const expectedOrgUid = getAppOrganizationUID();
if (!organization_uid) {
throw new BadRequestError(
"No organization was linked to this authorization. When you install or authorize the app in Contentstack, choose the organization that matches your Migration Tool SSO setup, then try again."
);
}
if (organization_uid !== expectedOrgUid) {
let orgLabel = expectedOrgUid;
try {
orgLabel = getAppOrganization().name;
} catch {
/* keep UID if app.json incomplete */
}
throw new BadRequestError(
`Organization mismatch: authorize this app in Contentstack for "${orgLabel}" (the organization from your SSO setup). You signed in under a different organization—select the correct one and try SSO again.`
);
}

// ── Fetch user FIRST so we have csUser.uid before the org check ──────────
const apiHost = regionalApiHosts[region as keyof typeof regionalApiHosts];
const [userErr, userRes] = await safePromise(
https({
method: "GET",
url: `https://${apiHost}/v3/user`,
headers: {
'authorization': `Bearer ${access_token}`,
},
headers: { 'authorization': `Bearer ${access_token}` },
})
);

if (userErr) {
logger.error("Error fetching user details with new token", userErr?.response?.data);
throw new InternalServerError(userErr);
}

const csUser = userRes?.data?.user;

// ── Org mismatch check — write failure to DB before throwing ─────────────
const expectedOrgUid = getAppOrganizationUID();
if (organization_uid !== expectedOrgUid) {
let orgLabel = expectedOrgUid;
try {
orgLabel = getAppOrganization().name;
} catch {
/* keep UID if app.json incomplete */
}

// Write terminal failure so the poller can stop immediately
try {
await AuthenticationModel.read();
const userIndex = AuthenticationModel.chain
.get("users")
.findIndex({ user_id: csUser?.uid })
.value();

AuthenticationModel.update((data: any) => {
const failureRecord = {
user_id: csUser?.uid,
email: csUser?.email,
region: region as string,
sso_failed: true,
sso_error: `Organization mismatch: authorize this app in Contentstack for "${orgLabel}" (the organization from your SSO setup). You signed in under a different organization—select the correct one and try SSO again.`,
updated_at: new Date().toISOString(),
};
if (userIndex >= 0) {
data.users[userIndex] = { ...data.users[userIndex], ...failureRecord };
} else {
data.users.push({ ...failureRecord, created_at: new Date().toISOString() });
}
});
} catch (dbErr) {
logger.error('Failed to write SSO org-mismatch failure to DB', dbErr);
}

throw new BadRequestError(
`Organization mismatch: authorize this app in Contentstack for "${orgLabel}" (the organization from your SSO setup). You signed in under a different organization—select the correct one and try SSO again.`
);
}
// ─────────────────────────────────────────────────────────────────────────

const appTokenPayload = {
region: region as string,
user_id: csUser?.uid,
user_id: csUser?.uid,
is_sso: true,
};

const appToken = generateToken(appTokenPayload);
await AuthenticationModel.read();
const userIndex = AuthenticationModel.chain.get("users").findIndex({ user_id: csUser?.uid }).value();
const userIndex = AuthenticationModel.chain
.get("users")
.findIndex({ user_id: csUser?.uid })
.value();

AuthenticationModel.update((data: any) => {
const userRecord = {
...appTokenPayload,
email: csUser?.email,
access_token: access_token,
access_token: access_token,
refresh_token: refresh_token,
organization_uid: organization_uid,
sso_failed: false, // clear any previous failure
sso_error: null,
updated_at: new Date().toISOString(),
};
if (userIndex < 0) {
Expand All @@ -344,17 +379,12 @@ const saveOAuthToken = async (req: Request): Promise<LoginServiceType> => {

logger.info(`Token and user data for ${csUser.email} (Region: ${region}) saved successfully.`);
return {
data: {
message: HTTP_TEXTS.SUCCESS_LOGIN,
app_token: appToken,
},
data: { message: HTTP_TEXTS.SUCCESS_LOGIN, app_token: appToken },
status: HTTP_CODES.OK,
}
};

} catch (error) {
if (error instanceof AppError) {
throw error;
}
if (error instanceof AppError) throw error;
logger.error("An error occurred during token exchange and save:", error);
throw new InternalServerError("Failed to process OAuth callback.");
}
Expand All @@ -379,7 +409,7 @@ export const refreshOAuthToken = async (userId: string): Promise<string> => {
throw new Error(`No refresh token available for user: ${userId}`);
}

const appConfigPath = path.join(process.cwd(), "..", 'app.json');
const appConfigPath = getAppJsonPath();
if (!fs.existsSync(appConfigPath)) {
throw new Error('app.json file not found - OAuth configuration required');
}
Expand Down Expand Up @@ -440,8 +470,8 @@ export const refreshOAuthToken = async (userId: string): Promise<string> => {
*/
export const getAppData = async () => {
try {
const appConfigPath = path.join(process.cwd(), '..','app.json');
const appConfigPath = getAppJsonPath();

if (!fs.existsSync(appConfigPath)) {
throw new Error('app.json file not found - SSO configuration required');
}
Expand Down Expand Up @@ -485,25 +515,43 @@ export const checkSSOAuthStatus = async (userId: string) => {
.find({ user_id: userId })
.value();

if (!userRecord || !userRecord?.access_token) {
if (!userRecord) {
return {
authenticated: false,
terminal: false,
message: 'SSO authentication not completed'
};
}

// ── Check for terminal failure written by the OAuth callback ─────────────
if (userRecord?.sso_failed) {
return {
authenticated: false,
terminal: true,
message: userRecord.sso_error ?? 'Organization mismatch: please select the correct organization and try SSO again.',
};
}
// ─────────────────────────────────────────────────────────────────────────

if (!userRecord?.access_token) {
return {
authenticated: false,
terminal: false,
message: 'SSO authentication not completed'
};
}

if (!userRecord?.organization_uid) {
return {
authenticated: false,
terminal: false,
message: 'Organization not linked to user'
};
}

const appOrgUID = getAppOrganizationUID();

if (userRecord.organization_uid !== appOrgUID) {
let detail =
'Organization mismatch: the authorized org does not match the Migration Tool SSO configuration.';
let detail = 'Organization mismatch: the authorized org does not match the Migration Tool SSO configuration.';
try {
const { name } = getAppOrganization();
detail = `Organization mismatch: authorize "${name}" in Contentstack (same org as SSO setup), then try again.`;
Expand All @@ -512,16 +560,16 @@ export const checkSSOAuthStatus = async (userId: string) => {
}
return {
authenticated: false,
terminal: true, // ← terminal, not just pending
message: detail,
};
}

const tokenAge =
Date.now() - new Date(userRecord.updated_at).getTime();

const tokenAge = Date.now() - new Date(userRecord.updated_at).getTime();
if (tokenAge > 10 * 60 * 1000) {
return {
authenticated: false,
terminal: true, // ← terminal, not just pending
message: 'SSO authentication expired'
};
}
Expand All @@ -534,6 +582,7 @@ export const checkSSOAuthStatus = async (userId: string) => {

return {
authenticated: true,
terminal: false,
message: 'SSO authentication successful',
app_token: appToken,
user: {
Expand All @@ -546,9 +595,7 @@ export const checkSSOAuthStatus = async (userId: string) => {

} catch (error: any) {
logger.error('SSO status check failed', error);
throw new Error(
`Failed to check SSO authentication status: ${error?.message}`
);
throw new Error(`Failed to check SSO authentication status: ${error?.message}`);
}
};

Expand Down
2 changes: 1 addition & 1 deletion api/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ const getUserProfile = async (req: Request): Promise<LoginServiceType> => {
.value();

if (userIndex < 0) throw new BadRequestError(HTTP_TEXTS.NO_CS_USER);
const { uid: org_uid, name: org_name } = getAppOrganization();
const userRecord = AuthenticationModel.data?.users?.[userIndex];
if (appTokenPayload?.is_sso === true) {
const { uid: org_uid, name: org_name } = getAppOrganization();
if (!userRecord?.access_token) {
throw new BadRequestError("SSO authentication not completed");
}
Expand Down
18 changes: 18 additions & 0 deletions api/src/utils/app-config-path.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import fs from "fs";
import path from "path";

/**
* Resolves the Contentstack app manifest path (decrypted app.json).
* Order: APP_CONFIG_PATH env → ./app.json next to cwd (Docker mount) → ../app.json (local monorepo).
*/
export function getAppJsonPath(): string {
const fromEnv = process.env.APP_CONFIG_PATH?.trim();
if (fromEnv) {
return fromEnv;
}
const nextToCwd = path.join(process.cwd(), "app.json");
if (fs.existsSync(nextToCwd)) {
return nextToCwd;
}
return path.join(process.cwd(), "..", "app.json");
}
4 changes: 2 additions & 2 deletions api/src/utils/auth.utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import fs from "fs";
import path from "path";
import AuthenticationModel from "../models/authentication.js";
import { UnauthorizedError } from "../utils/custom-errors.utils.js";
import { decryptAppConfig } from "./crypto.utils.js";
import { getAppJsonPath } from "./app-config-path.utils.js";

function loadAppConfig() {
const configPath = path.join(process.cwd(), "..", "app.json");
const configPath = getAppJsonPath();
if (!fs.existsSync(configPath)) {
throw new Error("app.json file not found");
}
Expand Down
9 changes: 7 additions & 2 deletions api/tests/unit/services/user.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe('user.service', () => {
expect(result.status).toBe(200);
expect(result.data.user.email).toBe('test@example.com');
expect(result.data.user.orgs).toHaveLength(2);
expect(getAppOrganization).not.toHaveBeenCalled();
});

it('should throw when user not found in AuthenticationModel', async () => {
Expand Down Expand Up @@ -205,14 +206,18 @@ describe('user.service', () => {
).rejects.toMatchObject({ message: 'Organization access revoked' });
});

it('should wrap unexpected errors in ExceptionFunction', async () => {
it('should wrap unexpected errors in ExceptionFunction (SSO path reads app org)', async () => {
mockChainValue.mockReturnValue(0);
vi.mocked(getAppOrganization).mockImplementationOnce(() => {
throw new Error('unexpected');
});

await expect(
userService.getUserProfile(createReq() as any)
userService.getUserProfile({
body: {
token_payload: { region: 'NA', user_id: 'user-123', is_sso: true },
},
} as any)
).rejects.toMatchObject({ message: 'unexpected' });
});
});
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
volumes:
- ${DOCKER_MOUNT_PATH:-${CMS_DATA_PATH}}:${CONTAINER_PATH}:ro
- shared_data:/app/extracted_files
- ./app.json:/usr/src/app/app.json:ro
environment:
- NODE_ENV=${NODE_ENV:-production}
- CMS_TYPE=${CMS_TYPE}
Expand Down
10 changes: 9 additions & 1 deletion setup-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ if [[ "$CMS_TYPE" == "drupal" ]]; then
echo "Drupal uses a MySQL database connection. Please provide your database details:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

read -rp "MySQL Host (e.g. host.docker.internal for host DB): " MYSQL_HOST
read -rp "MySQL Host [host.docker.internal] (use this when MySQL runs on your Mac/PC, not inside Docker): " MYSQL_HOST
MYSQL_HOST="${MYSQL_HOST:-host.docker.internal}"
read -rp "MySQL User: " MYSQL_USER
read -rsp "MySQL Password: " MYSQL_PASSWORD
echo ""
Expand Down Expand Up @@ -182,6 +183,13 @@ else
fi
fi

# Check for app.json (required by API for org + OAuth; mounted into the API container)
if [ ! -f "app.json" ]; then
echo "❌ app.json not found in the repository root."
echo " Run your Contentstack / OAuth setup so app.json exists, then retry Docker."
exit 1
fi

# Check if docker-compose.yml exists before running
if [ ! -f "docker-compose.yml" ]; then
echo "❌ docker-compose.yml not found in current directory"
Expand Down
Loading
Loading