Skip to content

Commit

Permalink
fix: Allow AuthType None to use valid API tokens (#6247)
Browse files Browse the repository at this point in the history
Fixes ##5799 and #5785

When you do not provide a token we should resolve to the "default"
environment to maintain backward compatibility. If you actually provide
a token we should prefer that and even block the request if it is not
valid.

An interesting fact is that "default" environment is not available on a
fresh installation of Unleash. This means that you need to provide a
token to actually get access to toggle configurations.


---------

Co-authored-by: Thomas Heartman <[email protected]>
  • Loading branch information
ivarconr and thomasheartman authored Feb 16, 2024
1 parent e5fe4a7 commit 4a81f09
Show file tree
Hide file tree
Showing 7 changed files with 216 additions and 27 deletions.
8 changes: 7 additions & 1 deletion src/lib/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import IndexRouter from './routes';
import requestLogger from './middleware/request-logger';
import demoAuthentication from './middleware/demo-authentication';
import ossAuthentication from './middleware/oss-authentication';
import noAuthentication from './middleware/no-authentication';
import noAuthentication, { noApiToken } from './middleware/no-authentication';
import secureHeaders from './middleware/secure-headers';

import { loadIndexHTML } from './util/load-index-html';
Expand All @@ -41,6 +41,7 @@ export default async function getApp(
const baseUriPath = config.server.baseUriPath || '';
const publicFolder = config.publicFolder || findPublicFolder();
const indexHTML = await loadIndexHTML(config, publicFolder);
const logger = config.getLogger('lib/app.ts');

app.set('trust proxy', true);
app.disable('x-powered-by');
Expand Down Expand Up @@ -147,6 +148,11 @@ export default async function getApp(
break;
}
case IAuthType.NONE: {
logger.warn(
'The AuthType=none option for Unleash is no longer recommended and will be removed in version 6.',
);
noApiToken(baseUriPath, app);
app.use(baseUriPath, apiTokenMiddleware(config, services));
noAuthentication(baseUriPath, app);
break;
}
Expand Down
50 changes: 44 additions & 6 deletions src/lib/middleware/no-authentication.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,51 @@
import { Application } from 'express';
import NoAuthUser from '../types/no-auth-user';
import { ApiTokenType } from '../types/models/api-token';
import {
ApiUser,
IApiRequest,
IAuthRequest,
permissions,
} from '../server-impl';
import { DEFAULT_ENV } from '../util';

// eslint-disable-next-line
function noneAuthentication(basePath: string, app: Application): void {
app.use(`${basePath || ''}/api/admin/`, (req, res, next) => {
// @ts-expect-error
if (!req.user) {
// @ts-expect-error
req.user = new NoAuthUser();
function noneAuthentication(baseUriPath: string, app: Application): void {
app.use(
`${baseUriPath || ''}/api/admin/`,
(req: IAuthRequest, res, next) => {
if (!req.user) {
req.user = new NoAuthUser();
}
next();
},
);
}

export function noApiToken(baseUriPath: string, app: Application) {
app.use(`${baseUriPath}/api/frontend`, (req: IApiRequest, res, next) => {
if (!req.headers.authorization && !req.user) {
req.user = new ApiUser({
tokenName: 'unknown',
permissions: [permissions.FRONTEND],
projects: ['*'],
environment: DEFAULT_ENV,
type: ApiTokenType.FRONTEND,
secret: 'unknown',
});
}
next();
});
app.use(`${baseUriPath}/api/client`, (req: IApiRequest, res, next) => {
if (!req.headers.authorization && !req.user) {
req.user = new ApiUser({
tokenName: 'unknown',
permissions: [permissions.CLIENT],
projects: ['*'],
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
secret: 'unknown',
});
}
next();
});
Expand Down
1 change: 0 additions & 1 deletion src/lib/openapi/spec/user-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export const userSchema = {
id: {
description: 'The user id',
type: 'integer',
minimum: 0,
example: 123,
},
isAPI: {
Expand Down
13 changes: 12 additions & 1 deletion src/lib/types/no-auth-user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ADMIN } from './permissions';
export default class NoAuthUser {
import { IUser } from './user';
export default class NoAuthUser implements IUser {
isAPI: boolean;

username: string;
Expand All @@ -8,13 +9,23 @@ export default class NoAuthUser {

permissions: string[];

name: string;
email: string;
inviteLink?: string | undefined;
seenAt?: Date | undefined;
createdAt?: Date | undefined;
loginAttempts?: number | undefined;
imageUrl: string;
accountType?: 'User' | 'Service Account' | undefined;

constructor(
username: string = 'unknown',
id: number = -1,
permissions: string[] = [ADMIN],
) {
this.isAPI = true;
this.username = username;
this.name = 'unknown';
this.id = id;
this.permissions = permissions;
}
Expand Down
135 changes: 135 additions & 0 deletions src/test/e2e/api/client/feature.auth-none.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
IUnleashTest,
setupAppWithCustomConfig,
} from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { DEFAULT_ENV } from '../../../../lib/util/constants';
import User from '../../../../lib/types/user';
import { ApiTokenType } from '../../../../lib/types/models/api-token';

let app: IUnleashTest;
let db: ITestDb;
const testUser = { name: 'test', id: -9999 } as User;
let clientSecret: string;
let frontendSecret: string;

beforeAll(async () => {
db = await dbInit('feature_api_client_auth_none', getLogger);
app = await setupAppWithCustomConfig(
db.stores,
{
authentication: {
type: 'none',
},
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
db.rawDatabase,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'feature_1',
description: 'the #1 feature',
impressionData: true,
},
'test',
testUser.id,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'feature_2',
description: 'soon to be the #1 feature',
},
'test',
testUser.id,
);

await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'feature_3',
description: 'terrible feature',
},
'test',
testUser.id,
);

const token = await app.services.apiTokenService.createApiTokenWithProjects(
{
tokenName: 'test',
type: ApiTokenType.CLIENT,
environment: DEFAULT_ENV,
projects: ['default'],
},
);
clientSecret = token.secret;

const frontendToken =
await app.services.apiTokenService.createApiTokenWithProjects({
tokenName: 'test',
type: ApiTokenType.FRONTEND,
environment: DEFAULT_ENV,
projects: ['default'],
});
frontendSecret = frontendToken.secret;
});

afterAll(async () => {
await app.destroy();
await db.destroy();
});

test('returns three feature toggles', async () => {
return app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(3);
});
});

test('returns 401 for incorrect api token', async () => {
return app.request
.get('/api/client/features')
.set('Authorization', 'some-invalid-token')
.expect('Content-Type', /json/)
.expect(401);
});

test('returns success for correct api token', async () => {
return app.request
.get('/api/client/features')
.set('Authorization', clientSecret)
.expect('Content-Type', /json/)
.expect(200);
});

test('returns successful for frontend API without token', async () => {
return app.request
.get('/api/frontend')
.expect('Content-Type', /json/)
.expect(200);
});

test('returns 401 for frontend API with invalid token', async () => {
return app.request
.get('/api/frontend')
.expect('Content-Type', /json/)
.set('Authorization', 'some-invalid-token')
.expect(401);
});

test('returns 200 for frontend API with valid token', async () => {
return app.request
.get('/api/frontend')
.expect('Content-Type', /json/)
.set('Authorization', frontendSecret)
.expect(200);
});
34 changes: 17 additions & 17 deletions src/test/e2e/api/client/feature.optimal304.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ beforeAll(async () => {
},
},
});
await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'featureX',
Expand All @@ -31,7 +31,7 @@ beforeAll(async () => {
'test',
testUser.id,
);
await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'featureY',
Expand All @@ -40,7 +40,7 @@ beforeAll(async () => {
'test',
testUser.id,
);
await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'featureZ',
Expand All @@ -49,7 +49,7 @@ beforeAll(async () => {
'test',
testUser.id,
);
await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'featureArchivedX',
Expand All @@ -59,12 +59,12 @@ beforeAll(async () => {
testUser.id,
);

await app.services.featureToggleServiceV2.archiveToggle(
await app.services.featureToggleService.archiveToggle(
'featureArchivedX',
testUser,
);

await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'featureArchivedY',
Expand All @@ -74,11 +74,11 @@ beforeAll(async () => {
testUser.id,
);

await app.services.featureToggleServiceV2.archiveToggle(
await app.services.featureToggleService.archiveToggle(
'featureArchivedY',
testUser,
);
await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'featureArchivedZ',
Expand All @@ -87,11 +87,11 @@ beforeAll(async () => {
'test',
testUser.id,
);
await app.services.featureToggleServiceV2.archiveToggle(
await app.services.featureToggleService.archiveToggle(
'featureArchivedZ',
testUser,
);
await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'feature.with.variants',
Expand All @@ -100,7 +100,7 @@ beforeAll(async () => {
'test',
testUser.id,
);
await app.services.featureToggleServiceV2.saveVariants(
await app.services.featureToggleService.saveVariants(
'feature.with.variants',
'default',
[
Expand Down Expand Up @@ -132,19 +132,19 @@ test('returns calculated hash', async () => {
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200);
expect(res.headers.etag).toBe('"ae443048:16"');
expect(res.body.meta.etag).toBe('"ae443048:16"');
expect(res.headers.etag).toBe('"61824cd0:16"');
expect(res.body.meta.etag).toBe('"61824cd0:16"');
});

test('returns 304 for pre-calculated hash', async () => {
return app.request
.get('/api/client/features')
.set('if-none-match', '"ae443048:16"')
.set('if-none-match', '"61824cd0:16"')
.expect(304);
});

test('returns 200 when content updates and hash does not match anymore', async () => {
await app.services.featureToggleServiceV2.createFeatureToggle(
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: 'featureNew304',
Expand All @@ -160,6 +160,6 @@ test('returns 200 when content updates and hash does not match anymore', async (
.set('if-none-match', 'ae443048:16')
.expect(200);

expect(res.headers.etag).toBe('"ae443048:17"');
expect(res.body.meta.etag).toBe('"ae443048:17"');
expect(res.headers.etag).toBe('"61824cd0:17"');
expect(res.body.meta.etag).toBe('"61824cd0:17"');
});
2 changes: 1 addition & 1 deletion website/docs/using-unleash/deploy/configuring-unleash.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ unleash.start(unleashOptions);
- `type` / `AUTH_TYPE`: `string` — What kind of authentication to use. Possible values
- `open-source` - Sign in with username and password. This is the default value.
- `custom` - If implementing your own authentication hook, use this
- `none` - Turn off authentication all together
- `demo` - Only requires an email to sign in (was default in v3)
- `none` - _Deprecated_ Turn off authentication completely. If no API token is provided towards /`api/client` or `/api/frontend` you will receive configuration for the "default" environment. We generally recommend you use the `demo` type for simple, insecure usage of Unleash. This auth type has many known limitations, particularly related to personalized capabilities such as favorites and [notifications](../../reference/notifications.md).
- `customAuthHandler`: function `(app: any, config: IUnleashConfig): void` — custom express middleware handling authentication. Used when type is set to `custom`. Can not be set via environment variables.
- `initialAdminUser`: `{ username: string, password: string} | null` — whether to create an admin user with default password - Defaults to using `admin` and `unleash4all` as the username and password. Can not be overridden by setting the `UNLEASH_DEFAULT_ADMIN_USERNAME` and `UNLEASH_DEFAULT_ADMIN_PASSWORD` environment variables.
- `initApiTokens` / `INIT_ADMIN_API_TOKENS`, `INIT_CLIENT_API_TOKENS`, and `INIT_FRONTEND_API_TOKENS`: `ApiTokens[]` — Array of API tokens to create on startup. The tokens will only be created if the database doesn't already contain any API tokens. Example:
Expand Down

0 comments on commit 4a81f09

Please sign in to comment.