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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ WORKOS_API_KEY=<your-workos-api-key>
WORKOS_CLIENT_ID=<your-workos-client-id>
WORKOS_ISSUER=
WORKOS_REDIRECT_URI=http://localhost:3000/auth/callback

# FraiOS Authentication
TOKEN_SECRET=<fraios-shared-secret>
ALLOWED_RETURN_TO_HOSTS=localhost
SIGNUP_ENABLED=false

Expand Down
1 change: 1 addition & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ services:
WORKOS_API_KEY: ${WORKOS_API_KEY}
WORKOS_CLIENT_ID: ${WORKOS_CLIENT_ID}
WORKOS_ISSUER: ${WORKOS_ISSUER:-}
TOKEN_SECRET: ${TOKEN_SECRET:-}
networks:
- synmetrix_default

Expand Down
5 changes: 4 additions & 1 deletion services/cubejs/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ HASURA_ENDPOINT=http://localhost:8080/v1/graphql

# CubeJS
CUBEJS_SECRET=7bbc4032cc82a017434deb9213dac701
CUBEJS_API_SECRET=7bbc4032cc82a017434deb9213dac701
CUBEJS_API_SECRET=7bbc4032cc82a017434deb9213dac701

# FraiOS
TOKEN_SECRET=test-fraios-secret-key-at-least-32-chars-long
22 changes: 16 additions & 6 deletions services/cubejs/src/routes/discover.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import YAML from "yaml";

import { verifyWorkOSToken } from "../utils/workosAuth.js";
import { findUser, provisionUserFromWorkOS } from "../utils/dataSourceHelpers.js";
import { detectTokenType, verifyWorkOSToken, verifyFraiOSToken } from "../utils/workosAuth.js";
import { findUser, provisionUserFromWorkOS, provisionUserFromFraiOS } from "../utils/dataSourceHelpers.js";
import { parseCubesFromJs } from "../utils/smart-generation/diffModels.js";

/**
Expand Down Expand Up @@ -82,7 +82,7 @@ export function buildDiscoverResponse(dataSources, partitionTeamIds) {
* GET /api/v1/discover
*
* Returns all datasources and cubes available to the authenticated user.
* Requires a WorkOS Bearer token. The user is auto-provisioned (JIT) if
* Requires a WorkOS or FraiOS Bearer token. The user is auto-provisioned (JIT) if
* this is their first request.
*
* Response:
Expand All @@ -97,7 +97,7 @@ export function buildDiscoverResponse(dataSources, partitionTeamIds) {
*/
export default async function discover(req, res) {
try {
// --- Auth: WorkOS JWT only ---
// --- Auth: WorkOS or FraiOS JWT ---
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(403).json({ error: "Authorization header required" });
Expand All @@ -111,8 +111,18 @@ export default async function discover(req, res) {
return res.status(403).json({ error: "Bearer token required" });
}

const payload = await verifyWorkOSToken(token);
const userId = await provisionUserFromWorkOS(payload);
const tokenType = detectTokenType(token);
let payload, userId;

if (tokenType === "workos") {
payload = await verifyWorkOSToken(token);
userId = await provisionUserFromWorkOS(payload);
} else if (tokenType === "fraios") {
payload = await verifyFraiOSToken(token);
userId = await provisionUserFromFraiOS(payload);
} else {
return res.status(403).json({ error: "WorkOS or FraiOS token required" });
}

// --- Fetch user's datasources across all team memberships ---
const user = await findUser({ userId });
Expand Down
20 changes: 13 additions & 7 deletions services/cubejs/src/routes/hasuraProxy.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";

import { detectTokenType, verifyWorkOSToken } from "../utils/workosAuth.js";
import { provisionUserFromWorkOS } from "../utils/dataSourceHelpers.js";
import { detectTokenType, verifyWorkOSToken, verifyFraiOSToken } from "../utils/workosAuth.js";
import { provisionUserFromWorkOS, provisionUserFromFraiOS } from "../utils/dataSourceHelpers.js";
import { mintHasuraToken } from "../utils/mintHasuraToken.js";
import { mintedTokenCache } from "../utils/mintedTokenCache.js";

Expand Down Expand Up @@ -74,10 +74,16 @@ export default function createHasuraProxy(config = {}) {

const tokenType = detectTokenType(token);

if (tokenType === "workos") {
// RS256 WorkOS token → verify, provision, mint HS256
const payload = await verifyWorkOSToken(token);
const userId = await provisionUserFromWorkOS(payload);
if (tokenType === "workos" || tokenType === "fraios") {
// WorkOS RS256 or FraiOS HS256 → verify, provision, mint Hasura HS256
let userId;
if (tokenType === "workos") {
const payload = await verifyWorkOSToken(token);
userId = await provisionUserFromWorkOS(payload);
} else {
const payload = await verifyFraiOSToken(token);
userId = await provisionUserFromFraiOS(payload);
}

// Check minted token cache
let hasuraToken = mintedTokenCache.get(userId);
Expand All @@ -94,7 +100,7 @@ export default function createHasuraProxy(config = {}) {
// Swap the Authorization header for Hasura
req.headers.authorization = `Bearer ${hasuraToken}`;
}
// HS256 tokens pass through unchanged
// HS256 Hasura tokens pass through unchanged

next();
} catch (err) {
Expand Down
137 changes: 137 additions & 0 deletions services/cubejs/src/utils/__tests__/provisionFraiOS.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert/strict";

// Mock fetchGraphQL before importing module under test
const fetchGraphQLMock = mock.fn();
mock.module("../graphql.js", {
namedExports: { fetchGraphQL: fetchGraphQLMock },
});
mock.module("../workosAuth.js", {
namedExports: {
fetchWorkOSUserProfile: mock.fn(),
detectTokenType: mock.fn(),
verifyWorkOSToken: mock.fn(),
verifyFraiOSToken: mock.fn(),
},
});

const {
provisionUserFromFraiOS,
invalidateWorkosSubCache,
} = await import("../dataSourceHelpers.js");

describe("provisionUserFromFraiOS", () => {
beforeEach(() => {
fetchGraphQLMock.mock.resetCalls();
invalidateWorkosSubCache(null); // clear cache between tests
});

it("returns userId when account found by email", async () => {
fetchGraphQLMock.mock.mockImplementation(async (query) => {
if (query.includes("FindAccountByEmail")) {
return {
data: {
accounts: [{ id: "acc-1", user_id: "user-uuid-1", email: "a@bonus.is" }],
},
};
}
if (query.includes("FindTeamByName")) {
return { data: { teams: [{ id: "team-1", user_id: "user-uuid-1" }] } };
}
if (query.includes("CreateMember")) {
return { data: { insert_members_one: null } };
}
if (query.includes("FindMember")) {
return {
data: {
members: [{ id: "member-1", member_roles: [{ id: "role-1" }] }],
},
};
}
return { data: {} };
});

const userId = await provisionUserFromFraiOS({
userId: "fraios-user-1",
email: "a@bonus.is",
accountId: "org-1",
partition: "bonus.is",
});

assert.equal(userId, "user-uuid-1");
});

it("provisions new user when no account found", async () => {
fetchGraphQLMock.mock.mockImplementation(async (query) => {
if (query.includes("FindAccountByEmail")) {
return { data: { accounts: [] } };
}
if (query.includes("CreateUser")) {
return { data: { insert_users_one: { id: "new-user-uuid" } } };
}
if (query.includes("CreateAccount")) {
return { data: { insert_auth_accounts_one: { id: "acc-new", user_id: "new-user-uuid" } } };
}
if (query.includes("FindTeamByName")) {
return { data: { teams: [] } };
}
if (query.includes("CreateTeam")) {
return { data: { insert_teams_one: { id: "team-new" } } };
}
if (query.includes("CreateMember")) {
return { data: { insert_members_one: { id: "member-new" } } };
}
if (query.includes("CreateMemberRole")) {
return { data: { insert_member_roles_one: { id: "role-new" } } };
}
return { data: {} };
});

const userId = await provisionUserFromFraiOS({
userId: "fraios-user-2",
email: "b@bonus.is",
accountId: "org-2",
partition: "bonus.is",
});

assert.equal(userId, "new-user-uuid");
});

it("uses cache on second call with same fraios userId", async () => {
let callCount = 0;
fetchGraphQLMock.mock.mockImplementation(async (query) => {
if (query.includes("FindAccountByEmail")) {
callCount++;
return {
data: {
accounts: [{ id: "acc-3", user_id: "user-uuid-3", email: "c@bonus.is" }],
},
};
}
if (query.includes("FindTeamByName")) {
return { data: { teams: [{ id: "team-3" }] } };
}
if (query.includes("CreateMember")) {
return { data: { insert_members_one: null } };
}
if (query.includes("FindMember")) {
return {
data: { members: [{ id: "m-3", member_roles: [{ id: "r-3" }] }] },
};
}
return { data: {} };
});

const payload = {
userId: "fraios-user-3",
email: "c@bonus.is",
accountId: "org-3",
partition: "bonus.is",
};

await provisionUserFromFraiOS(payload);
await provisionUserFromFraiOS(payload);

assert.equal(callCount, 1, "should hit cache on second call");
});
});
95 changes: 95 additions & 0 deletions services/cubejs/src/utils/__tests__/workosAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import { SignJWT } from "jose";

// Set TOKEN_SECRET before importing module under test
const TEST_TOKEN_SECRET = "test-fraios-secret-key-at-least-32-chars!!";
process.env.TOKEN_SECRET = TEST_TOKEN_SECRET;

const { detectTokenType, verifyFraiOSToken } = await import(
"../workosAuth.js"
);

// --- Helpers ---

function makeHS256Token(payload, secret) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(new TextEncoder().encode(secret));
}

// --- detectTokenType ---

describe("detectTokenType", () => {
it('returns "fraios" for HS256 token with accountId claim', async () => {
const token = await makeHS256Token(
{ userId: "u1", email: "a@b.com", accountId: "org1" },
TEST_TOKEN_SECRET
);
assert.equal(detectTokenType(token), "fraios");
});

it('returns "hasura" for HS256 token with hasura namespace', async () => {
const token = await makeHS256Token(
{ hasura: { "x-hasura-user-id": "u1" } },
"any-secret-that-is-long-enough-ok"
);
assert.equal(detectTokenType(token), "hasura");
});

it('returns "hasura" for malformed token', () => {
assert.equal(detectTokenType("not.a.jwt"), "hasura");
});
});

// --- verifyFraiOSToken ---

describe("verifyFraiOSToken", () => {
it("verifies a valid FraiOS token and returns payload", async () => {
const token = await makeHS256Token(
{
userId: "u1",
email: "user@example.com",
accountId: "org1",
partition: "bonus.is",
},
TEST_TOKEN_SECRET
);
const payload = await verifyFraiOSToken(token);
assert.equal(payload.userId, "u1");
assert.equal(payload.email, "user@example.com");
assert.equal(payload.accountId, "org1");
assert.equal(payload.partition, "bonus.is");
});

it("rejects a token signed with wrong secret", async () => {
const token = await makeHS256Token(
{ userId: "u1", email: "a@b.com", accountId: "org1" },
"wrong-secret-that-is-long-enough!!"
);
await assert.rejects(() => verifyFraiOSToken(token), (err) => {
assert.equal(err.status, 403);
return true;
});
});

it("rejects an expired token", async () => {
const token = await new SignJWT({
userId: "u1",
email: "a@b.com",
accountId: "org1",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(Math.floor(Date.now() / 1000) - 7200)
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600)
.sign(new TextEncoder().encode(TEST_TOKEN_SECRET));

await assert.rejects(() => verifyFraiOSToken(token), (err) => {
assert.equal(err.status, 403);
assert.ok(err.message.includes("expired"));
return true;
});
});
});
7 changes: 6 additions & 1 deletion services/cubejs/src/utils/checkAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import jwt from "jsonwebtoken";
import {
findUser,
provisionUserFromWorkOS,
provisionUserFromFraiOS,
} from "./dataSourceHelpers.js";
import { detectTokenType, verifyWorkOSToken } from "./workosAuth.js";
import { detectTokenType, verifyWorkOSToken, verifyFraiOSToken } from "./workosAuth.js";
import defineUserScope from "./defineUserScope.js";

const { JWT_KEY, JWT_ALGORITHM } = process.env;
Expand Down Expand Up @@ -50,6 +51,10 @@ const checkAuth = async (req) => {
// WorkOS RS256 path
const payload = await verifyWorkOSToken(authToken);
userId = await provisionUserFromWorkOS(payload);
} else if (tokenType === "fraios") {
// FraiOS HS256 path
const payload = await verifyFraiOSToken(authToken);
userId = await provisionUserFromFraiOS(payload);
} else {
// Hasura HS256 path (existing)
let jwtDecoded;
Expand Down
Loading
Loading