Skip to content

Commit 198c861

Browse files
feat(api): implement self-host secret guard and update auth controller for self-hosted login
1 parent 2299dce commit 198c861

File tree

7 files changed

+50
-35
lines changed

7 files changed

+50
-35
lines changed

apps/api/src/app/auth/auth.controller.ts

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { SwitchOrganizationCommand } from './usecases/switch-organization/switch
4242
import { SwitchOrganization } from './usecases/switch-organization/switch-organization.usecase';
4343
import { AuthService } from './services/auth.service';
4444
import { SelfHostUsecase } from './usecases/self-host/self-host.usecase';
45+
import { SelfHostSecretGuard } from './framework/self-host-secret.guard';
4546

4647
@ApiCommonResponses()
4748
@Controller('/auth')
@@ -192,6 +193,7 @@ export class AuthController {
192193
}
193194

194195
@Get('/self-hosted')
196+
@UseGuards(SelfHostSecretGuard)
195197
async logMeIn() {
196198
return this.selfHostUsecase.execute();
197199
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
2+
import { HttpRequestHeaderKeysEnum } from '@novu/application-generic';
3+
4+
@Injectable()
5+
export class SelfHostSecretGuard implements CanActivate {
6+
canActivate(context: ExecutionContext): boolean {
7+
const secretKey = process.env.SELF_HOSTED_SECRET_KEY;
8+
if (!secretKey) return true;
9+
10+
const request = context.switchToHttp().getRequest();
11+
const headerKey = request.headers[HttpRequestHeaderKeysEnum.NOVU_SELF_HOSTED_SECRET_KEY.toLowerCase()];
12+
13+
if (!headerKey) {
14+
throw new UnauthorizedException('Missing self-host secret key');
15+
}
16+
17+
if (headerKey !== secretKey) {
18+
throw new UnauthorizedException('Invalid self-host secret key');
19+
}
20+
21+
return true;
22+
}
23+
}

apps/dashboard/src/api/api.client.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ const request = async <T>(
8181
}
8282
};
8383

84-
type RequestOptions = { body?: unknown; environment?: IEnvironment; signal?: AbortSignal };
84+
type RequestOptions = { body?: unknown; environment?: IEnvironment; signal?: AbortSignal; headers?: HeadersInit };
8585

86-
export const get = <T>(endpoint: string, { environment, signal }: RequestOptions = {}) =>
87-
request<T>(endpoint, { method: 'GET', environment, signal });
86+
export const get = <T>(endpoint: string, { environment, signal, headers }: RequestOptions = {}) =>
87+
request<T>(endpoint, { method: 'GET', environment, signal, headers });
8888
export const post = <T>(endpoint: string, options: RequestOptions) =>
8989
request<T>(endpoint, { method: 'POST', ...options });
9090
export const put = <T>(endpoint: string, options: RequestOptions) =>

apps/dashboard/src/config/index.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ export const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
1212

1313
export const APP_ID = import.meta.env.VITE_NOVU_APP_ID || '';
1414

15-
export const API_HOSTNAME = window._env_.VITE_API_HOSTNAME || import.meta.env.VITE_API_HOSTNAME;
15+
export const API_HOSTNAME = window._env_?.VITE_API_HOSTNAME || import.meta.env.VITE_API_HOSTNAME;
1616

1717
export const IS_EU = API_HOSTNAME === 'https://eu.api.novu.co';
1818

19-
export const WEBSOCKET_HOSTNAME = window._env_.VITE_WEBSOCKET_HOSTNAME || import.meta.env.VITE_WEBSOCKET_HOSTNAME;
19+
export const WEBSOCKET_HOSTNAME = window._env_?.VITE_WEBSOCKET_HOSTNAME || import.meta.env.VITE_WEBSOCKET_HOSTNAME;
2020

2121
export const INTERCOM_APP_ID = import.meta.env.VITE_INTERCOM_APP_ID;
2222

@@ -32,6 +32,8 @@ export const ONBOARDING_DEMO_WORKFLOW_ID = 'onboarding-demo-workflow';
3232

3333
export const IS_SELF_HOSTED = import.meta.env.VITE_SELF_HOSTED;
3434

35+
export const SELF_HOSTED_SECRET_KEY = import.meta.env.VITE_SELF_HOSTED_SECRET_KEY;
36+
3537
if (!IS_SELF_HOSTED && !CLERK_PUBLISHABLE_KEY) {
3638
throw new Error('Missing Clerk Publishable Key');
3739
}

apps/dashboard/src/utils/self-hosted/jwt-manager.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { get } from '../../api/api.client';
2+
import { SELF_HOSTED_SECRET_KEY } from '../../config';
23

34
const JWT_STORAGE_KEY = 'self-hosted-jwt';
45

56
export async function refreshJwt(): Promise<string | null> {
67
try {
7-
const result = await get<{ data: { token: string } }>('/auth/self-hosted');
8+
const headers: HeadersInit = SELF_HOSTED_SECRET_KEY
9+
? { 'novu-self-hosted-secret-key': SELF_HOSTED_SECRET_KEY }
10+
: {};
11+
const result = await get<{ data: { token: string } }>('/auth/self-hosted', { headers });
812
const token = result?.data?.token;
913

1014
if (token) {

libs/application-generic/src/http/headers.types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export enum HttpRequestHeaderKeysEnum {
1212
NOVU_USER_AGENT = 'Novu-User-Agent',
1313
BYPASS_TUNNEL_REMINDER = 'Bypass-Tunnel-Reminder',
1414
IDEMPOTENCY_KEY = 'Idempotency-Key',
15+
NOVU_SELF_HOSTED_SECRET_KEY = 'Novu-Self-Hosted-Secret-Key',
1516
}
1617
testHttpHeaderEnumValidity(HttpRequestHeaderKeysEnum);
1718

libs/application-generic/src/http/utils.types.ts

+12-29
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,6 @@
22
import * as nestSwagger from '@nestjs/swagger';
33
import { ApiHeaderOptions } from '@nestjs/swagger';
44

5-
export enum HttpRequestHeaderKeysEnum {
6-
AUTHORIZATION = 'Authorization',
7-
USER_AGENT = 'User-Agent',
8-
CONTENT_TYPE = 'Content-Type',
9-
SENTRY_TRACE = 'Sentry-Trace',
10-
NOVU_ENVIRONMENT_ID = 'Novu-Environment-Id',
11-
NOVU_API_VERSION = 'Novu-API-Version',
12-
NOVU_USER_AGENT = 'Novu-User-Agent',
13-
BYPASS_TUNNEL_REMINDER = 'Bypass-Tunnel-Reminder',
14-
}
15-
testHttpHeaderEnumValidity(HttpRequestHeaderKeysEnum);
16-
175
export enum HttpResponseHeaderKeysEnum {
186
CONTENT_TYPE = 'Content-Type',
197
RATELIMIT_REMAINING = 'RateLimit-Remaining',
@@ -54,22 +42,20 @@ export type DeepRequired<T> = T extends object
5442
/**
5543
* Transform S to CONSTANT_CASE.
5644
*/
57-
export type ConvertToConstantCase<S extends string> =
58-
S extends `${infer T}-${infer U}`
59-
? `${Uppercase<T>}_${ConvertToConstantCase<U>}`
60-
: Uppercase<S>;
45+
export type ConvertToConstantCase<S extends string> = S extends `${infer T}-${infer U}`
46+
? `${Uppercase<T>}_${ConvertToConstantCase<U>}`
47+
: Uppercase<S>;
6148

6249
/**
6350
* Validate that S is in Http-Header-Case, and return S if valid, otherwise never.
6451
*/
65-
export type ValidateHttpHeaderCase<S extends string> =
66-
S extends `${infer U}-${infer V}`
67-
? U extends Capitalize<U>
68-
? `${U}-${ValidateHttpHeaderCase<V>}`
69-
: never
70-
: S extends Capitalize<S>
71-
? `${S}` // necessary to cast to string literal type for non-hyphenated enum validation
72-
: never;
52+
export type ValidateHttpHeaderCase<S extends string> = S extends `${infer U}-${infer V}`
53+
? U extends Capitalize<U>
54+
? `${U}-${ValidateHttpHeaderCase<V>}`
55+
: never
56+
: S extends Capitalize<S>
57+
? `${S}` // necessary to cast to string literal type for non-hyphenated enum validation
58+
: never;
7359

7460
/**
7561
* Helper function to test that Header enum keys and values match correct format.
@@ -102,14 +88,11 @@ export type ValidateHttpHeaderCase<S extends string> =
10288
export function testHttpHeaderEnumValidity<
10389
TEnum extends IConstants,
10490
TValue extends TEnum[keyof TEnum] & string,
105-
IConstants = Record<
106-
ConvertToConstantCase<TValue>,
107-
ValidateHttpHeaderCase<TValue>
108-
>,
91+
IConstants = Record<ConvertToConstantCase<TValue>, ValidateHttpHeaderCase<TValue>>,
10992
>(
11093
testEnum: TEnum &
11194
Record<
11295
Exclude<keyof TEnum, keyof IConstants>,
11396
['Key must be the CONSTANT_CASED version of the Capital-Cased value']
114-
>,
97+
>
11598
) {}

0 commit comments

Comments
 (0)