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
47 changes: 17 additions & 30 deletions .github/workflows/build-and-push-images.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
name: Build and Push Docker Images

on:
push:
branches:
- main
- master
tags:
- 'v*'
pull_request:
branches:
- main
- master
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., 0.27.5)'
required: false
type: string

env:
REGISTRY: ghcr.io
Expand Down Expand Up @@ -51,39 +38,39 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.ANALYTICS_SERVICE_IMAGE }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}

- name: Extract metadata for analytics-ui
id: meta-ui
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.ANALYTICS_UI_IMAGE }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}

- name: Extract metadata for analytics-bootstrap
id: meta-bootstrap
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.ANALYTICS_BOOTSTRAP_IMAGE }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}

- name: Build and push analytics-service
uses: docker/build-push-action@v5
with:
context: ./analytics-ai-service
file: ./analytics-ai-service/docker/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
cache-from: type=gha
Expand All @@ -95,7 +82,7 @@ jobs:
with:
context: ./analytics-ui
file: ./analytics-ui/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-ui.outputs.tags }}
labels: ${{ steps.meta-ui.outputs.labels }}
cache-from: type=gha
Expand All @@ -107,7 +94,7 @@ jobs:
with:
context: ./docker/bootstrap
file: ./docker/bootstrap/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-bootstrap.outputs.tags }}
labels: ${{ steps.meta-bootstrap.outputs.labels }}
cache-from: type=gha
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Migration: cleanup_custom_auth_tables
*
* Removes tables that were used by the old custom JWT auth system.
* These are no longer needed after migrating to NextAuth v4:
* - refresh_token: manual refresh token rotation (NextAuth handles session via JWT cookie)
* - oauth_state: custom OAuth PKCE/state storage (NextAuth handles this internally)
* - user_session: custom session tracking (replaced by NextAuth session-token cookie)
*/
exports.up = async (knex) => {
await knex.schema.dropTableIfExists('refresh_token');
await knex.schema.dropTableIfExists('oauth_state');
await knex.schema.dropTableIfExists('user_session');
};

exports.down = async (knex) => {
// Recreate tables for rollback if needed
await knex.schema.createTableIfNotExists('refresh_token', (table) => {
table.increments('id').primary();
table.integer('user_id').notNullable();
table.string('token_hash', 64).notNullable().unique();
table.string('family_id', 36).notNullable();
table.boolean('is_revoked').defaultTo(false);
table.timestamp('expires_at').notNullable();
table.string('ip_address', 45);
table.string('user_agent', 512);
table.timestamps(true, true);
});

await knex.schema.createTableIfNotExists('oauth_state', (table) => {
table.increments('id').primary();
table.string('state', 64).notNullable().unique();
table.string('provider', 64).notNullable();
table.string('code_verifier', 128);
table.timestamp('expires_at').notNullable();
table.timestamps(true, true);
});

await knex.schema.createTableIfNotExists('user_session', (table) => {
table.increments('id').primary();
table.integer('user_id').notNullable();
table.string('session_token', 64).notNullable().unique();
table.timestamp('expires_at').notNullable();
table.timestamps(true, true);
});
};
1 change: 1 addition & 0 deletions analytics-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"micro": "^9.4.1",
"micro-cors": "^0.1.1",
"next": "14.2.32",
"next-auth": "^4.24.13",
"pg": "^8.8.0",
"pg-cursor": "^2.7.4",
"posthog-node": "^4.3.2",
Expand Down
175 changes: 17 additions & 158 deletions analytics-ui/src/apollo/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,172 +1,31 @@
import { ApolloClient, HttpLink, InMemoryCache, from, Observable } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { ApolloClient, HttpLink, InMemoryCache, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { signOut } from 'next-auth/react';
import errorHandler from '@/utils/errorHandler';

const ACCESS_TOKEN_KEY = 'nqrust_access_token';
const REFRESH_TOKEN_KEY = 'nqrust_refresh_token';
const apolloErrorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
const hasAuthError = graphQLErrors?.some(
(err) => err.extensions?.code === 'UNAUTHENTICATED'
);

// Track in-flight refresh to prevent concurrent refresh storms
let isRefreshing = false;

// Queue holds both the retry callback and observer so we can error out on failure
interface PendingRequest {
resolve: () => void;
reject: (err: Error) => void;
}
let pendingRequests: PendingRequest[] = [];

// Listeners notified when tokens are refreshed (so React state can sync)
type TokenRefreshListener = (accessToken: string) => void;
const tokenRefreshListeners = new Set<TokenRefreshListener>();
export const onTokenRefresh = (listener: TokenRefreshListener) => {
tokenRefreshListeners.add(listener);
return () => tokenRefreshListeners.delete(listener);
};

const resolvePendingRequests = () => {
pendingRequests.forEach((req) => req.resolve());
pendingRequests = [];
};

const rejectPendingRequests = (err: Error) => {
pendingRequests.forEach((req) => req.reject(err));
pendingRequests = [];
};

const forceLogout = () => {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
window.location.href = '/login';
};

const apolloErrorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
const hasAuthError = graphQLErrors?.some(
(err) => err.extensions?.code === 'UNAUTHENTICATED'
);

if (hasAuthError) {
// Don't intercept auth mutations themselves
const opName = operation.operationName;
if (
opName === 'Login' ||
opName === 'RefreshToken' ||
opName === 'Register'
) {
return;
}

if (!isRefreshing) {
isRefreshing = true;
const refreshToken =
typeof window !== 'undefined'
? localStorage.getItem(REFRESH_TOKEN_KEY)
: null;

if (!refreshToken) {
forceLogout();
return;
}

// Use raw fetch to avoid Apollo error loop
fetch('/api/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `mutation RefreshToken($refreshToken: String!) {
refreshToken(refreshToken: $refreshToken) {
accessToken
refreshToken
}
}`,
variables: { refreshToken },
}),
})
.then((res) => res.json())
.then((result) => {
if (result.data?.refreshToken) {
const newAccessToken = result.data.refreshToken.accessToken;
localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken);
localStorage.setItem(
REFRESH_TOKEN_KEY,
result.data.refreshToken.refreshToken
);
// Notify React state (useAuth) so it stays in sync
tokenRefreshListeners.forEach((fn) => fn(newAccessToken));
resolvePendingRequests();
} else {
rejectPendingRequests(new Error('Token refresh failed'));
forceLogout();
}
})
.catch((err) => {
// Only force logout for non-connectivity errors (e.g. invalid refresh token)
// For genuine network failures, keep the user logged in — they can retry
const isNetworkFailure =
err instanceof TypeError ||
(err?.message?.toLowerCase()?.includes('failed to fetch'));
if (isNetworkFailure) {
// Network is down; error out pending operations so spinners stop
rejectPendingRequests(
new Error('Network unavailable. Please try again.'),
);
} else {
rejectPendingRequests(err);
forceLogout();
}
})
.finally(() => {
isRefreshing = false;
});
}

// Queue the failed operation to retry after refresh completes
return new Observable((observer) => {
pendingRequests.push({
resolve: () => {
const newToken = localStorage.getItem(ACCESS_TOKEN_KEY);
operation.setContext(({ headers = {} }: { headers: Record<string, string> }) => ({
headers: {
...headers,
authorization: newToken ? `Bearer ${newToken}` : '',
},
}));
forward(operation).subscribe(observer);
},
reject: (err: Error) => {
observer.error(err);
},
});
});
}

// Non-auth errors: delegate to existing handler
errorHandler({ graphQLErrors, networkError, operation, forward });
if (hasAuthError) {
// Sign out via NextAuth to properly clear the session cookie before redirecting.
// Using window.location.href would skip cookie cleanup and risk a redirect loop.
signOut({ callbackUrl: '/login' });
return;
}
);

const httpLink = new HttpLink({
uri: '/api/graphql',
// Non-auth errors: delegate to existing handler
errorHandler({ graphQLErrors, networkError, operation, forward });
});

// Auth link to add token to requests
const authLink = setContext((_, { headers }) => {
let token: string | null = null;
if (typeof window !== 'undefined') {
token = localStorage.getItem(ACCESS_TOKEN_KEY);
}

return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
const httpLink = new HttpLink({
uri: '/api/graphql',
credentials: 'include', // Send NextAuth session cookie with every GraphQL request
});

const client = new ApolloClient({
link: from([apolloErrorLink, authLink, httpLink]),
link: from([apolloErrorLink, httpLink]),
cache: new InMemoryCache(),
});

Expand Down
13 changes: 12 additions & 1 deletion analytics-ui/src/apollo/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ const config = {
licensePublicKey: process.env.LICENSE_PUBLIC_KEY?.replace(/\\n/g, '\n'),
};

let _configWarned = false;
export function getConfig(): IConfig {
return { ...defaultConfig, ...pickBy(config) };
const result = { ...defaultConfig, ...pickBy(config) } as IConfig;
if (!_configWarned) {
_configWarned = true;
if (result.encryptionPassword === defaultConfig.encryptionPassword) {
console.warn('[security] ENCRYPTION_PASSWORD is using the default insecure value. Set the ENCRYPTION_PASSWORD environment variable before going to production.');
}
if (result.encryptionSalt === defaultConfig.encryptionSalt) {
console.warn('[security] ENCRYPTION_SALT is using the default insecure value. Set the ENCRYPTION_SALT environment variable before going to production.');
}
}
return result;
}
Loading
Loading