From 4a81f0932f9428fcba6d319f936eec2cd91da6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Fri, 16 Feb 2024 09:24:56 +0100 Subject: [PATCH] fix: Allow AuthType None to use valid API tokens (#6247) 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 --- src/lib/app.ts | 8 +- src/lib/middleware/no-authentication.ts | 50 ++++++- src/lib/openapi/spec/user-schema.ts | 1 - src/lib/types/no-auth-user.ts | 13 +- .../api/client/feature.auth-none.e2e.test.ts | 135 ++++++++++++++++++ .../api/client/feature.optimal304.e2e.test.ts | 34 ++--- .../deploy/configuring-unleash.md | 2 +- 7 files changed, 216 insertions(+), 27 deletions(-) create mode 100644 src/test/e2e/api/client/feature.auth-none.e2e.test.ts diff --git a/src/lib/app.ts b/src/lib/app.ts index b658628fe1..6e5ed9e5b4 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -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'; @@ -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'); @@ -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; } diff --git a/src/lib/middleware/no-authentication.ts b/src/lib/middleware/no-authentication.ts index c4fe1e2aa6..1d6c9fac87 100644 --- a/src/lib/middleware/no-authentication.ts +++ b/src/lib/middleware/no-authentication.ts @@ -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(); }); diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts index 31a1535752..92500837e4 100644 --- a/src/lib/openapi/spec/user-schema.ts +++ b/src/lib/openapi/spec/user-schema.ts @@ -11,7 +11,6 @@ export const userSchema = { id: { description: 'The user id', type: 'integer', - minimum: 0, example: 123, }, isAPI: { diff --git a/src/lib/types/no-auth-user.ts b/src/lib/types/no-auth-user.ts index 821b906d41..2e54ccc98f 100644 --- a/src/lib/types/no-auth-user.ts +++ b/src/lib/types/no-auth-user.ts @@ -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; @@ -8,6 +9,15 @@ 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, @@ -15,6 +25,7 @@ export default class NoAuthUser { ) { this.isAPI = true; this.username = username; + this.name = 'unknown'; this.id = id; this.permissions = permissions; } diff --git a/src/test/e2e/api/client/feature.auth-none.e2e.test.ts b/src/test/e2e/api/client/feature.auth-none.e2e.test.ts new file mode 100644 index 0000000000..c651e7e1ee --- /dev/null +++ b/src/test/e2e/api/client/feature.auth-none.e2e.test.ts @@ -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); +}); diff --git a/src/test/e2e/api/client/feature.optimal304.e2e.test.ts b/src/test/e2e/api/client/feature.optimal304.e2e.test.ts index 219ecd4032..bd03817885 100644 --- a/src/test/e2e/api/client/feature.optimal304.e2e.test.ts +++ b/src/test/e2e/api/client/feature.optimal304.e2e.test.ts @@ -21,7 +21,7 @@ beforeAll(async () => { }, }, }); - await app.services.featureToggleServiceV2.createFeatureToggle( + await app.services.featureToggleService.createFeatureToggle( 'default', { name: 'featureX', @@ -31,7 +31,7 @@ beforeAll(async () => { 'test', testUser.id, ); - await app.services.featureToggleServiceV2.createFeatureToggle( + await app.services.featureToggleService.createFeatureToggle( 'default', { name: 'featureY', @@ -40,7 +40,7 @@ beforeAll(async () => { 'test', testUser.id, ); - await app.services.featureToggleServiceV2.createFeatureToggle( + await app.services.featureToggleService.createFeatureToggle( 'default', { name: 'featureZ', @@ -49,7 +49,7 @@ beforeAll(async () => { 'test', testUser.id, ); - await app.services.featureToggleServiceV2.createFeatureToggle( + await app.services.featureToggleService.createFeatureToggle( 'default', { name: 'featureArchivedX', @@ -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', @@ -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', @@ -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', @@ -100,7 +100,7 @@ beforeAll(async () => { 'test', testUser.id, ); - await app.services.featureToggleServiceV2.saveVariants( + await app.services.featureToggleService.saveVariants( 'feature.with.variants', 'default', [ @@ -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', @@ -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"'); }); diff --git a/website/docs/using-unleash/deploy/configuring-unleash.md b/website/docs/using-unleash/deploy/configuring-unleash.md index 1c56b68e04..722b808186 100644 --- a/website/docs/using-unleash/deploy/configuring-unleash.md +++ b/website/docs/using-unleash/deploy/configuring-unleash.md @@ -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: