From c40dcd4a5746d8ad52759ec01fd4ba0a9b45d9c3 Mon Sep 17 00:00:00 2001 From: Kyle Baran Date: Mon, 12 Aug 2024 17:54:12 -0700 Subject: [PATCH] Updated JWT authentication to handle RSA signatures New authentication-setting fields for JWT algorithm and JWT public key. Keys should be inserted as strings with '\n' replacing new lines. Replaced the last of the API.instance.client uses with Engine.instance.api Added a new service that will return the JWT public key. Replaced current jsonwebtoken.decode calls with jsonwebtoken.verify. decode does not check if the signature is valid. Resolves IR-3827 --- packages/client-core/i18n/en/admin.json | 2 + packages/client-core/src/API.ts | 20 +------ .../settings/tabs/authentication.tsx | 18 +++++- .../InstanceChat/InstanceChat.skiptest.tsx | 4 +- .../src/social/services/LocationService.ts | 9 ++- .../UserMenu/menus/LocationMenu.tsx | 4 +- .../src/user/services/AuthService.ts | 41 +++++++------ .../src/util/wait-for-client-authenticated.ts | 4 +- packages/client-core/tests/createMockAPI.ts | 32 +++++----- .../setting/authentication-setting.schema.ts | 4 +- .../src/schemas/user/jwt-public-key.schema.ts | 29 +++++++++ packages/ecs/package.json | 2 + packages/ecs/src/Engine.ts | 10 +++- packages/instanceserver/src/channels.ts | 19 ++++-- packages/server-core/src/appconfig.ts | 9 ++- .../server-core/src/hooks/authenticate.ts | 5 +- .../authentication-setting.seed.ts | 3 + .../20240812231215_increase-secret-length.ts | 44 ++++++++++++++ .../migrations/20240813205359_jwt-fields.ts | 59 +++++++++++++++++++ .../identity-provider.hooks.ts | 2 +- .../jwt-public-key/jwt-public-key.class.ts | 49 +++++++++++++++ .../jwt-public-key/jwt-public-key.docs.ts | 34 +++++++++++ .../jwt-public-key/jwt-public-key.hooks.ts | 58 ++++++++++++++++++ .../src/user/jwt-public-key/jwt-public-key.ts | 51 ++++++++++++++++ packages/server-core/src/user/services.ts | 4 +- .../editor/properties/portal/index.tsx | 2 +- scripts/update-project.ts | 4 +- 27 files changed, 436 insertions(+), 86 deletions(-) create mode 100644 packages/common/src/schemas/user/jwt-public-key.schema.ts create mode 100644 packages/server-core/src/setting/authentication-setting/migrations/20240812231215_increase-secret-length.ts create mode 100644 packages/server-core/src/setting/authentication-setting/migrations/20240813205359_jwt-fields.ts create mode 100755 packages/server-core/src/user/jwt-public-key/jwt-public-key.class.ts create mode 100755 packages/server-core/src/user/jwt-public-key/jwt-public-key.docs.ts create mode 100755 packages/server-core/src/user/jwt-public-key/jwt-public-key.hooks.ts create mode 100755 packages/server-core/src/user/jwt-public-key/jwt-public-key.ts diff --git a/packages/client-core/i18n/en/admin.json b/packages/client-core/i18n/en/admin.json index d40aa031d7..6548925dad 100755 --- a/packages/client-core/i18n/en/admin.json +++ b/packages/client-core/i18n/en/admin.json @@ -367,6 +367,8 @@ "service": "Service", "githubAppId": "App ID (Enter for GitHub App, omit for OAuth App)", "secret": "Secret", + "jwtAlgorithm": "JWT Algorithm", + "jwtPublicKey": "JWT Public Key", "entity": "Entity", "authStrategies": "Authentication Strategies", "userName": "User Name", diff --git a/packages/client-core/src/API.ts b/packages/client-core/src/API.ts index 1cd5d857c9..0b3891c036 100755 --- a/packages/client-core/src/API.ts +++ b/packages/client-core/src/API.ts @@ -23,30 +23,17 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import type { AuthenticationClient } from '@feathersjs/authentication-client' import authentication from '@feathersjs/authentication-client' import feathers from '@feathersjs/client' -import type { FeathersApplication } from '@feathersjs/feathers' import Primus from 'primus-client' -import type { ServiceTypes } from '@etherealengine/common/declarations' import config from '@etherealengine/common/src/config' import { Engine } from '@etherealengine/ecs/src/Engine' import primusClient from './util/primus-client' -export type FeathersClient = FeathersApplication & - AuthenticationClient & { - primus: Primus - authentication: AuthenticationClient - } - /**@deprecated - use 'Engine.instance.api' instead */ export class API { - /**@deprecated - use 'Engine.instance.api' instead */ - static instance: API - client: FeathersClient - static createAPI = () => { const feathersClient = feathers() @@ -61,13 +48,8 @@ export class API { }) ) - primus.on('reconnected', () => API.instance.client.reAuthenticate(true)) - - API.instance = new API() - API.instance.client = feathersClient as any + primus.on('reconnected', () => feathersClient.reAuthenticate(true)) Engine.instance.api = feathersClient } } - -globalThis.API = API diff --git a/packages/client-core/src/admin/components/settings/tabs/authentication.tsx b/packages/client-core/src/admin/components/settings/tabs/authentication.tsx index fea7923523..d4478446d6 100644 --- a/packages/client-core/src/admin/components/settings/tabs/authentication.tsx +++ b/packages/client-core/src/admin/components/settings/tabs/authentication.tsx @@ -199,6 +199,20 @@ const AuthenticationTab = forwardRef(({ open }: { open: boolean }, ref: React.Mu /> + + + + diff --git a/packages/client-core/src/components/InstanceChat/InstanceChat.skiptest.tsx b/packages/client-core/src/components/InstanceChat/InstanceChat.skiptest.tsx index 618e544a5f..15e3ce36d4 100644 --- a/packages/client-core/src/components/InstanceChat/InstanceChat.skiptest.tsx +++ b/packages/client-core/src/components/InstanceChat/InstanceChat.skiptest.tsx @@ -30,12 +30,12 @@ import { createRoot } from 'react-dom/client' import { ChannelID, MessageID, UserID } from '@etherealengine/common/src/schema.type.module' import { createEngine } from '@etherealengine/ecs' +import { Engine } from '@etherealengine/ecs/src/Engine' import { getMutableState } from '@etherealengine/hyperflux' import { InstanceChat } from '.' import { createDOM } from '../../../tests/createDOM' import { createMockAPI } from '../../../tests/createMockAPI' -import { API } from '../../API' import { ChannelState } from '../../social/services/ChannelService' describe('Instance Chat Component', () => { @@ -46,7 +46,7 @@ describe('Instance Chat Component', () => { rootContainer = document.createElement('div') document.body.appendChild(rootContainer) createEngine() - API.instance = createMockAPI() + Engine.instance.api = createMockAPI() }) afterEach(() => { diff --git a/packages/client-core/src/social/services/LocationService.ts b/packages/client-core/src/social/services/LocationService.ts index 689fd5c084..b46739e5ed 100755 --- a/packages/client-core/src/social/services/LocationService.ts +++ b/packages/client-core/src/social/services/LocationService.ts @@ -37,7 +37,6 @@ import { Engine } from '@etherealengine/ecs/src/Engine' import { defineState, getMutableState, getState } from '@etherealengine/hyperflux' import { useEffect } from 'react' -import { API } from '../../API' import { NotificationService } from '../../common/services/NotificationService' import { AuthState } from '../../user/services/AuthService' @@ -141,7 +140,7 @@ export const LocationService = { getLocation: async (locationId: LocationID) => { try { LocationState.fetchingCurrentSocialLocation() - const location = await API.instance.client.service(locationPath).get(locationId) + const location = await Engine.instance.api.service(locationPath).get(locationId) LocationState.socialLocationRetrieved(location) } catch (err) { NotificationService.dispatchNotify(err.message, { variant: 'error' }) @@ -149,7 +148,7 @@ export const LocationService = { }, getLocationByName: async (locationName: string) => { LocationState.fetchingCurrentSocialLocation() - const locationResult = (await API.instance.client.service(locationPath).find({ + const locationResult = (await Engine.instance.api.service(locationPath).find({ query: { slugifiedName: locationName } @@ -167,7 +166,7 @@ export const LocationService = { } }, getLobby: async () => { - const lobbyResult = (await API.instance.client.service(locationPath).find({ + const lobbyResult = (await Engine.instance.api.service(locationPath).find({ query: { isLobby: true, $limit: 1 @@ -182,7 +181,7 @@ export const LocationService = { }, banUserFromLocation: async (userId: UserID, locationId: LocationID) => { try { - await API.instance.client.service(locationBanPath).create({ + await Engine.instance.api.service(locationBanPath).create({ userId: userId, locationId: locationId }) diff --git a/packages/client-core/src/user/components/UserMenu/menus/LocationMenu.tsx b/packages/client-core/src/user/components/UserMenu/menus/LocationMenu.tsx index f72efa207e..7dd4650901 100755 --- a/packages/client-core/src/user/components/UserMenu/menus/LocationMenu.tsx +++ b/packages/client-core/src/user/components/UserMenu/menus/LocationMenu.tsx @@ -28,6 +28,7 @@ import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { locationPath, LocationType } from '@etherealengine/common/src/schema.type.module' +import { Engine } from '@etherealengine/ecs/src/Engine' import Button from '@etherealengine/ui/src/primitives/mui/Button' import Icon from '@etherealengine/ui/src/primitives/mui/Icon' import InputAdornment from '@etherealengine/ui/src/primitives/mui/InputAdornment' @@ -40,7 +41,6 @@ import TableRow from '@etherealengine/ui/src/primitives/mui/TableRow' import TextField from '@etherealengine/ui/src/primitives/mui/TextField' import Typography from '@etherealengine/ui/src/primitives/mui/Typography' -import { API } from '../../../../API' import { LocationSeed } from '../../../../social/services/LocationService' import styles from '../index.module.scss' @@ -68,7 +68,7 @@ const LocationMenu = (props: Props) => { }, []) const fetchLocations = (page: number, rows: number, search?: string) => { - API.instance.client + Engine.instance.api .service(locationPath) .find({ query: { diff --git a/packages/client-core/src/user/services/AuthService.ts b/packages/client-core/src/user/services/AuthService.ts index 1f20161471..33a117a0ff 100755 --- a/packages/client-core/src/user/services/AuthService.ts +++ b/packages/client-core/src/user/services/AuthService.ts @@ -57,6 +57,7 @@ import { userPath, userSettingPath } from '@etherealengine/common/src/schema.type.module' +import type { FeathersClient } from '@etherealengine/ecs/src/Engine' import { Engine } from '@etherealengine/ecs/src/Engine' import { defineState, @@ -65,7 +66,6 @@ import { syncStateWithLocalStorage, useHookstate } from '@etherealengine/hyperflux' -import { API } from '../../API' import { NotificationService } from '../../common/services/NotificationService' export const logger = multiLogger.child({ component: 'client-core:AuthService' }) @@ -170,7 +170,7 @@ export interface LinkedInLoginForm { */ async function _resetToGuestToken(options = { reset: true }) { if (options.reset) { - await API.instance.client.authentication.reset() + await (Engine.instance.api as FeathersClient).authentication.reset() } const newProvider = await Engine.instance.api.service(identityProviderPath).create({ type: 'guest', @@ -179,7 +179,7 @@ async function _resetToGuestToken(options = { reset: true }) { }) const accessToken = newProvider.accessToken! console.log(`Created new guest accessToken: ${accessToken}`) - await API.instance.client.authentication.setAccessToken(accessToken as string) + await (Engine.instance.api as FeathersClient).authentication.setAccessToken(accessToken as string) return accessToken } @@ -195,22 +195,26 @@ export const AuthService = { const accessToken = !forceClientAuthReset && authState?.authUser?.accessToken?.value if (forceClientAuthReset) { - await API.instance.client.authentication.reset() + await (Engine.instance.api as FeathersClient).authentication.reset() } if (accessToken) { - await API.instance.client.authentication.setAccessToken(accessToken as string) + await (Engine.instance.api as FeathersClient).authentication.setAccessToken(accessToken as string) } else { await _resetToGuestToken({ reset: false }) } let res: AuthenticationResult try { - res = await API.instance.client.reAuthenticate() + res = await (Engine.instance.api as FeathersClient).reAuthenticate() } catch (err) { - if (err.className === 'not-found' || (err.className === 'not-authenticated' && err.message === 'jwt expired')) { + if ( + err.className === 'not-found' || + (err.className === 'not-authenticated' && err.message === 'jwt expired') || + (err.className === 'not-authenticated' && err.message === 'invalid algorithm') + ) { authState.merge({ isLoggedIn: false, user: UserSeed, authUser: AuthUserSeed }) await _resetToGuestToken() - res = await API.instance.client.reAuthenticate() + res = await (Engine.instance.api as FeathersClient).reAuthenticate() } else { logger.error(err, 'Error re-authenticating') throw err @@ -222,7 +226,7 @@ export const AuthService = { if (!identityProvider?.id) { authState.merge({ isLoggedIn: false, user: UserSeed, authUser: AuthUserSeed }) await _resetToGuestToken() - res = await API.instance.client.reAuthenticate() + res = await (Engine.instance.api as FeathersClient).reAuthenticate() } const authUser = resolveAuthUser(res) // authUser is now { accessToken, authentication, identityProvider } @@ -243,15 +247,14 @@ export const AuthService = { async loadUserData(userId: UserID) { try { - const client = API.instance.client - const user = await client.service(userPath).get(userId) + const user = await Engine.instance.api.service(userPath).get(userId) if (!user.userSetting) { - const settingsRes = (await client + const settingsRes = (await Engine.instance.api .service(userSettingPath) .find({ query: { userId: userId } })) as Paginated if (settingsRes.total === 0) { - user.userSetting = await client.service(userSettingPath).create({ userId: userId }) + user.userSetting = await Engine.instance.api.service(userSettingPath).create({ userId: userId }) } else { user.userSetting = settingsRes.data[0] } @@ -278,7 +281,7 @@ export const AuthService = { authState.merge({ isProcessing: true, error: '' }) try { - const authenticationResult = await API.instance.client.authenticate({ + const authenticationResult = await (Engine.instance.api as FeathersClient).authenticate({ strategy: 'local', email: form.email, password: form.password @@ -392,8 +395,8 @@ export const AuthService = { if (newTokenResult?.token) { getMutableState(AuthState).merge({ isProcessing: true, error: '' }) - await API.instance.client.authentication.setAccessToken(newTokenResult.token) - const res = await API.instance.client.reAuthenticate(true) + await (Engine.instance.api as FeathersClient).authentication.setAccessToken(newTokenResult.token) + const res = await (Engine.instance.api as FeathersClient).reAuthenticate(true) const authUser = resolveAuthUser(res) await Engine.instance.api.service(identityProviderPath).remove(ipToRemove.id) const authState = getMutableState(AuthState) @@ -409,8 +412,8 @@ export const AuthService = { const authState = getMutableState(AuthState) authState.merge({ isProcessing: true, error: '' }) try { - await API.instance.client.authentication.setAccessToken(accessToken as string) - const res = await API.instance.client.authenticate({ + await (Engine.instance.api as FeathersClient).authentication.setAccessToken(accessToken as string) + const res = await (Engine.instance.api as FeathersClient).authenticate({ strategy: 'jwt', accessToken }) @@ -459,7 +462,7 @@ export const AuthService = { const authState = getMutableState(AuthState) authState.merge({ isProcessing: true, error: '' }) try { - await API.instance.client.logout() + await (Engine.instance.api as FeathersClient).logout() authState.merge({ isLoggedIn: false, user: UserSeed, authUser: AuthUserSeed }) } catch (_) { authState.merge({ isLoggedIn: false, user: UserSeed, authUser: AuthUserSeed }) diff --git a/packages/client-core/src/util/wait-for-client-authenticated.ts b/packages/client-core/src/util/wait-for-client-authenticated.ts index b52082e8dc..bd8124c61d 100644 --- a/packages/client-core/src/util/wait-for-client-authenticated.ts +++ b/packages/client-core/src/util/wait-for-client-authenticated.ts @@ -23,9 +23,7 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { Engine } from '@etherealengine/ecs/src/Engine' - -import { FeathersClient } from '../API' +import { Engine, FeathersClient } from '@etherealengine/ecs/src/Engine' async function waitForClientAuthenticated(): Promise { const api = Engine.instance.api as FeathersClient diff --git a/packages/client-core/tests/createMockAPI.ts b/packages/client-core/tests/createMockAPI.ts index 12dd36d036..b6ff66e219 100644 --- a/packages/client-core/tests/createMockAPI.ts +++ b/packages/client-core/tests/createMockAPI.ts @@ -23,7 +23,7 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { API } from '../src/API' +import type { FeathersClient } from '@etherealengine/ecs/src/Engine' type MockFeathers = { on: (type: string, cb: () => void) => void @@ -42,23 +42,21 @@ type ServicesToMock = { export const createMockAPI = (servicesToMock?: ServicesToMock) => { return { - client: { - service: (service: string) => { - if (servicesToMock && servicesToMock[service]) { - return servicesToMock[service] - } else { - return { - on: (type, cb) => {}, - off: (type, cb) => {}, - find: (type) => {}, - get: (type) => {}, - create: (type) => {}, - patch: (type) => {}, - update: (type) => {}, - remove: (type) => {} - } + service: (service: string) => { + if (servicesToMock && servicesToMock[service]) { + return servicesToMock[service] + } else { + return { + on: (type, cb) => {}, + off: (type, cb) => {}, + find: (type) => {}, + get: (type) => {}, + create: (type) => {}, + patch: (type) => {}, + update: (type) => {}, + remove: (type) => {} } } } - } as unknown as API + } as FeathersClient } diff --git a/packages/common/src/schemas/setting/authentication-setting.schema.ts b/packages/common/src/schemas/setting/authentication-setting.schema.ts index 53832a1e85..997bd96aac 100644 --- a/packages/common/src/schemas/setting/authentication-setting.schema.ts +++ b/packages/common/src/schemas/setting/authentication-setting.schema.ts @@ -124,7 +124,9 @@ export const authenticationSettingSchema = Type.Object( }), service: Type.String(), entity: Type.String(), - secret: Type.String(), + secret: Type.String({ maxLength: 4095 }), + jwtAlgorithm: Type.Optional(Type.String()), + jwtPublicKey: Type.Optional(Type.String({ maxLength: 1023 })), authStrategies: Type.Array(Type.Ref(authStrategiesSchema)), jwtOptions: Type.Optional(Type.Ref(authJwtOptionsSchema)), bearerToken: Type.Optional(Type.Ref(authBearerTokenSchema)), diff --git a/packages/common/src/schemas/user/jwt-public-key.schema.ts b/packages/common/src/schemas/user/jwt-public-key.schema.ts new file mode 100644 index 0000000000..b92674a549 --- /dev/null +++ b/packages/common/src/schemas/user/jwt-public-key.schema.ts @@ -0,0 +1,29 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +// For more information about this file see https://dove.feathersjs.com/guides/cli/service.schemas.html + +export const jwtPublicKeyPath = 'jwt-public-key' +export const jwtPublicKeyMethods = ['find'] as const diff --git a/packages/ecs/package.json b/packages/ecs/package.json index e2954ac2f2..a0e7142204 100755 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -16,7 +16,9 @@ "dependencies": { "@etherealengine/common": "^1.6.0", "@etherealengine/hyperflux": "^1.6.0", + "@feathersjs/authentication-client": "5.0.5", "bitecs": "0.3.40", + "primus-client": "^7.3.4", "three": "0.158.0", "uuid": "9.0.0" }, diff --git a/packages/ecs/src/Engine.ts b/packages/ecs/src/Engine.ts index 3a8464df67..1690422b51 100755 --- a/packages/ecs/src/Engine.ts +++ b/packages/ecs/src/Engine.ts @@ -23,9 +23,11 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ +import type { AuthenticationClient } from '@feathersjs/authentication-client' import type { FeathersApplication } from '@feathersjs/feathers' import * as bitECS from 'bitecs' import { getAllEntities } from 'bitecs' +import Primus from 'primus-client' import { Cache } from 'three' import type { ServiceTypes } from '@etherealengine/common/declarations' @@ -40,10 +42,16 @@ import { removeEntity } from './EntityFunctions' import { removeQuery } from './QueryFunctions' import { SystemState } from './SystemState' +export type FeathersClient = FeathersApplication & + AuthenticationClient & { + primus: Primus + authentication: AuthenticationClient + } + export class Engine { static instance: Engine - api: FeathersApplication + api: FeathersApplication | FeathersClient /** The uuid of the logged-in user */ userID: UserID diff --git a/packages/instanceserver/src/channels.ts b/packages/instanceserver/src/channels.ts index ec99989535..84737bd452 100755 --- a/packages/instanceserver/src/channels.ts +++ b/packages/instanceserver/src/channels.ts @@ -27,7 +27,7 @@ import { Paginated } from '@feathersjs/feathers/lib' import '@feathersjs/transport-commons' -import { decode } from 'jsonwebtoken' +import { verify } from 'jsonwebtoken' import { channelPath, @@ -73,6 +73,7 @@ import getLocalServerIp from '@etherealengine/server-core/src/util/get-local-ser import './InstanceServerModule' import { initializeSpatialEngine } from '@etherealengine/spatial/src/initializeEngine' +import { NotAuthenticated } from '@feathersjs/errors' import { InstanceServerState } from './InstanceServerState' import { authorizeUserToJoinServer, handleDisconnect, setupIPs } from './NetworkFunctions' import { restartInstanceServer } from './restartInstanceServer' @@ -597,10 +598,15 @@ export const onConnection = (app: Application) => async (connection: PrimusConne if (!connection.socketQuery?.token) return - const authResult = await app.service('authentication').strategies.jwt.authenticate!( - { accessToken: connection.socketQuery.token }, - {} - ) + let authResult + try { + authResult = await app.service('authentication').strategies.jwt.authenticate!( + { accessToken: connection.socketQuery.token }, + {} + ) + } catch (err) { + return new NotAuthenticated(err) + } const identityProvider = authResult[identityProviderPath] as IdentityProviderType if (!identityProvider?.id) return @@ -727,7 +733,8 @@ const onDisconnection = (app: Application) => async (connection: PrimusConnectio authResult = await app.service('authentication').strategies.jwt.authenticate!({ accessToken: token }, {}) } catch (err) { if (err.code === 401 && err.data.name === 'TokenExpiredError') { - const jwtDecoded = decode(token)! + const algorithms = process.env.APP_ENV === 'development' ? 'HS256' : 'RS256' + const jwtDecoded = verify(token, config.authentication.secret, { algorithms: [algorithms] })! const idProvider = await app.service(identityProviderPath).get(jwtDecoded.sub as string) authResult = { [identityProviderPath]: idProvider diff --git a/packages/server-core/src/appconfig.ts b/packages/server-core/src/appconfig.ts index 4eaab2969b..c724a9d06e 100755 --- a/packages/server-core/src/appconfig.ts +++ b/packages/server-core/src/appconfig.ts @@ -37,6 +37,7 @@ import { githubRepoAccessWebhookPath } from '@etherealengine/common/src/schemas/ import { identityProviderPath } from '@etherealengine/common/src/schemas/user/identity-provider.schema' import { loginPath } from '@etherealengine/common/src/schemas/user/login.schema' +import { jwtPublicKeyPath } from '@etherealengine/common/src/schemas/user/jwt-public-key.schema' import multiLogger from './ServerLogger' import { APPLE_SCOPES, @@ -257,9 +258,12 @@ type WhiteListItem = { const authentication = { service: identityProviderPath, entity: identityProviderPath, - secret: process.env.AUTH_SECRET!, + secret: process.env.AUTH_SECRET!.split(String.raw`\n`).join('\n'), authStrategies: ['jwt', 'apple', 'discord', 'facebook', 'github', 'google', 'linkedin', 'twitter', 'didWallet'], + jwtAlgorithm: process.env.JWT_ALGORITHM, + jwtPublicKey: process.env.JWT_PUBLIC_KEY, jwtOptions: { + algorithm: process.env.JWT_ALGORITHM || 'HS256', expiresIn: '30 days' }, bearerToken: { @@ -276,7 +280,8 @@ const authentication = { { path: routePath, methods: ['find'] }, { path: acceptInvitePath, methods: ['get'] }, { path: discordBotAuthPath, methods: ['find'] }, - { path: loginPath, methods: ['get'] } + { path: loginPath, methods: ['get'] }, + { path: jwtPublicKeyPath, methods: ['find'] } ] as (string | WhiteListItem)[], callback: { apple: process.env.APPLE_CALLBACK_URL || `${client.url}/auth/oauth/apple`, diff --git a/packages/server-core/src/hooks/authenticate.ts b/packages/server-core/src/hooks/authenticate.ts index c27b0edf96..41f9eccdb9 100644 --- a/packages/server-core/src/hooks/authenticate.ts +++ b/packages/server-core/src/hooks/authenticate.ts @@ -33,7 +33,7 @@ import { isProvider } from 'feathers-hooks-common' import { userApiKeyPath, UserApiKeyType } from '@etherealengine/common/src/schemas/user/user-api-key.schema' import { userPath, UserType } from '@etherealengine/common/src/schemas/user/user.schema' -import { decode, JwtPayload } from 'jsonwebtoken' +import { JwtPayload, verify } from 'jsonwebtoken' import { Application } from '../../declarations' import config from '../appconfig' @@ -70,7 +70,8 @@ export default async (context: HookContext, next: NextFunction): Pr if (context.arguments[1]?.token && context.path === 'project' && context.method === 'update') { const appId = config.authentication.oauth.github.appId ? parseInt(config.authentication.oauth.github.appId) : null const token = context.arguments[1].token - const jwtDecoded = decode(token)! as JwtPayload + const algorithms = process.env.APP_ENV === 'development' ? 'HS256' : 'RS256' + const jwtDecoded = verify(token, config.authentication.secret, { algorithms: [algorithms] })! as JwtPayload if (jwtDecoded.iss == null || parseInt(jwtDecoded.iss) !== appId) throw new NotAuthenticated('Invalid app credentials') const octoKit = new Octokit({ auth: token }) diff --git a/packages/server-core/src/setting/authentication-setting/authentication-setting.seed.ts b/packages/server-core/src/setting/authentication-setting/authentication-setting.seed.ts index 67cccdb00b..0beb18b294 100644 --- a/packages/server-core/src/setting/authentication-setting/authentication-setting.seed.ts +++ b/packages/server-core/src/setting/authentication-setting/authentication-setting.seed.ts @@ -51,6 +51,8 @@ export async function seed(knex: Knex): Promise { service: identityProviderPath, entity: identityProviderPath, secret: process.env.AUTH_SECRET || 'test', + jwtAlgorithm: process.env.JWT_ALGORITHM, + jwtPublicKey: process.env.JWT_PUBLIC_KEY, authStrategies: JSON.stringify([ { jwt: true }, { smsMagicLink: true }, @@ -65,6 +67,7 @@ export async function seed(knex: Knex): Promise { { didWallet: true } ]), jwtOptions: JSON.stringify({ + algorithm: process.env.JWT_ALGORITHM || 'HS256', expiresIn: '30 days' }), bearerToken: JSON.stringify({ diff --git a/packages/server-core/src/setting/authentication-setting/migrations/20240812231215_increase-secret-length.ts b/packages/server-core/src/setting/authentication-setting/migrations/20240812231215_increase-secret-length.ts new file mode 100644 index 0000000000..55cd21f495 --- /dev/null +++ b/packages/server-core/src/setting/authentication-setting/migrations/20240812231215_increase-secret-length.ts @@ -0,0 +1,44 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import type { Knex } from 'knex' + +import { authenticationSettingPath } from '@etherealengine/common/src/schemas/setting/authentication-setting.schema' + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(authenticationSettingPath, async (table) => { + table.string('secret', 4095).nullable().alter() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex: Knex): Promise {} diff --git a/packages/server-core/src/setting/authentication-setting/migrations/20240813205359_jwt-fields.ts b/packages/server-core/src/setting/authentication-setting/migrations/20240813205359_jwt-fields.ts new file mode 100644 index 0000000000..d6e5f934cf --- /dev/null +++ b/packages/server-core/src/setting/authentication-setting/migrations/20240813205359_jwt-fields.ts @@ -0,0 +1,59 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import type { Knex } from 'knex' + +import { authenticationSettingPath } from '@etherealengine/common/src/schemas/setting/authentication-setting.schema' + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(authenticationSettingPath, async (table) => { + table.string('jwtAlgorithm').defaultTo('HS256') + table.string('jwtPublicKey', 1023).nullable() + }) + + const authSettings = await knex.table(authenticationSettingPath).first() + + if (authSettings) { + await knex.table(authenticationSettingPath).update({ + jwtAlgorithm: process.env.JWT_ALGORITHM, + jwtPublicKey: process.env.JWT_PUBLIC_KEY + }) + } +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(authenticationSettingPath, async (table) => { + table.dropColumn('jwtAlgorithm') + table.dropColumn('jwtPublicKey') + }) +} diff --git a/packages/server-core/src/user/identity-provider/identity-provider.hooks.ts b/packages/server-core/src/user/identity-provider/identity-provider.hooks.ts index 787a24f480..e8576bf50d 100755 --- a/packages/server-core/src/user/identity-provider/identity-provider.hooks.ts +++ b/packages/server-core/src/user/identity-provider/identity-provider.hooks.ts @@ -170,7 +170,7 @@ async function validateAuthParams(context: HookContext) { accessToken: context.params.authentication.accessToken }, {} ) - if (userId !== authResult[appConfig.authentication.entity]?.userId) + if (userId?.length > 0 && userId !== authResult[appConfig.authentication.entity]?.userId) throw new BadRequest('Cannot make identity-providers on other users') } else { if (userId && existingUser) diff --git a/packages/server-core/src/user/jwt-public-key/jwt-public-key.class.ts b/packages/server-core/src/user/jwt-public-key/jwt-public-key.class.ts new file mode 100755 index 0000000000..fa9a8afed1 --- /dev/null +++ b/packages/server-core/src/user/jwt-public-key/jwt-public-key.class.ts @@ -0,0 +1,49 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { Params, ServiceInterface } from '@feathersjs/feathers' +import { Application } from '../../../declarations' +import appconfig from '../../appconfig' + +/** + * A class for Login service + */ +export class JWTPublicKeyService implements ServiceInterface { + app: Application + + constructor(app: Application) { + this.app = app + } + + /** + * A function which find specific login details + * + * @param params + * @returns {token} + */ + async find(params?: Params) { + return appconfig.authentication.jwtPublicKey + } +} diff --git a/packages/server-core/src/user/jwt-public-key/jwt-public-key.docs.ts b/packages/server-core/src/user/jwt-public-key/jwt-public-key.docs.ts new file mode 100755 index 0000000000..307200bd25 --- /dev/null +++ b/packages/server-core/src/user/jwt-public-key/jwt-public-key.docs.ts @@ -0,0 +1,34 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { createSwaggerServiceOptions } from 'feathers-swagger' + +export default createSwaggerServiceOptions({ + schemas: {}, + docs: { + description: 'JWT Public Key endpoint', + securities: ['all'] + } +}) diff --git a/packages/server-core/src/user/jwt-public-key/jwt-public-key.hooks.ts b/packages/server-core/src/user/jwt-public-key/jwt-public-key.hooks.ts new file mode 100755 index 0000000000..4c26f836b6 --- /dev/null +++ b/packages/server-core/src/user/jwt-public-key/jwt-public-key.hooks.ts @@ -0,0 +1,58 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { disallow } from 'feathers-hooks-common' + +export default { + before: { + all: [], + find: [], + get: [disallow()], + create: [disallow()], + update: [disallow()], + patch: [disallow()], + remove: [disallow()] + }, + + after: { + all: [], + find: [], + get: [], + create: [], + update: [], + patch: [], + remove: [] + }, + + error: { + all: [], + find: [], + get: [], + create: [], + update: [], + patch: [], + remove: [] + } +} as any diff --git a/packages/server-core/src/user/jwt-public-key/jwt-public-key.ts b/packages/server-core/src/user/jwt-public-key/jwt-public-key.ts new file mode 100755 index 0000000000..5d0242fb8c --- /dev/null +++ b/packages/server-core/src/user/jwt-public-key/jwt-public-key.ts @@ -0,0 +1,51 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +// Initializes the `login` service on path `/login` + +import { jwtPublicKeyMethods, jwtPublicKeyPath } from '@etherealengine/common/src/schemas/user/jwt-public-key.schema' +import { Application } from '../../../declarations' +import { JWTPublicKeyService } from './jwt-public-key.class' +import jwtPublicKeyDocs from './jwt-public-key.docs' +import hooks from './jwt-public-key.hooks' + +declare module '@etherealengine/common/declarations' { + interface ServiceTypes { + [jwtPublicKeyPath]: JWTPublicKeyService + } +} + +export default (app: Application): void => { + app.use(jwtPublicKeyPath, new JWTPublicKeyService(app), { + // A list of all methods this service exposes externally + methods: jwtPublicKeyMethods, + // You can add additional custom events to be sent to clients here + events: [], + docs: jwtPublicKeyDocs + }) + + const service = app.service(jwtPublicKeyPath) + service.hooks(hooks) +} diff --git a/packages/server-core/src/user/services.ts b/packages/server-core/src/user/services.ts index 4efa38c03e..81616af0e4 100755 --- a/packages/server-core/src/user/services.ts +++ b/packages/server-core/src/user/services.ts @@ -32,6 +32,7 @@ import GithubRepoAccessRefresh from './github-repo-access-refresh/github-repo-ac import GithubRepoAccessWebhook from './github-repo-access-webhook/github-repo-access-webhook' import GithubRepoAccess from './github-repo-access/github-repo-access' import IdentityProvider from './identity-provider/identity-provider' +import JwtPublicKey from './jwt-public-key/jwt-public-key' import LoginToken from './login-token/login-token' import Login from './login/login' import MagicLink from './magic-link/magic-link' @@ -66,5 +67,6 @@ export default [ GithubRepoAccess, GithubRepoAccessRefresh, GithubRepoAccessWebhook, - GenerateToken + GenerateToken, + JwtPublicKey ] diff --git a/packages/ui/src/components/editor/properties/portal/index.tsx b/packages/ui/src/components/editor/properties/portal/index.tsx index 9f32fc3a63..52e8fd58a4 100644 --- a/packages/ui/src/components/editor/properties/portal/index.tsx +++ b/packages/ui/src/components/editor/properties/portal/index.tsx @@ -96,7 +96,7 @@ export const PortalNodeEditor: EditorComponentType = (props) => { try { portalsDetail .push - //...((await API.instance.client.service(portalPath).find({ query: { paginate: false } })) as PortalType[]) + //...((await Engine.instance.api.service(portalPath).find({ query: { paginate: false } })) as PortalType[]) () console.log('portalsDetail', portalsDetail, getComponent(props.entity, UUIDComponent)) } catch (error) { diff --git a/scripts/update-project.ts b/scripts/update-project.ts index 5ef0df26c5..06b53b6133 100644 --- a/scripts/update-project.ts +++ b/scripts/update-project.ts @@ -34,7 +34,7 @@ import { createFeathersKoaApp, serverJobPipe } from '@etherealengine/server-core import { updateAppConfig } from '@etherealengine/server-core/src/updateAppConfig' import { NotAuthenticated } from '@feathersjs/errors' import { Octokit } from '@octokit/rest' -import { JwtPayload, decode } from 'jsonwebtoken' +import { JwtPayload, verify } from 'jsonwebtoken' dotenv.config({ path: appRootPath.path, @@ -82,7 +82,7 @@ cli.main(async () => { if (data.token) { const appId = config.authentication.oauth.github.appId ? parseInt(config.authentication.oauth.github.appId) : null const token = data.token - const jwtDecoded = decode(token)! as JwtPayload + const jwtDecoded = verify(token, config.authentication.secret, { algorithms: ['RS256'] })! as JwtPayload if (jwtDecoded.iss == null || parseInt(jwtDecoded.iss) !== appId) throw new NotAuthenticated('Invalid app credentials') const octoKit = new Octokit({ auth: token })