auth IoC 1 - initializeDefaultAppsFactory

This commit is contained in:
Kristaps Fabians Geikins 2024-09-17 14:04:37 +03:00
Родитель 9992a9bd1d
Коммит 68376b91c1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 16D20A16730A0111
12 изменённых файлов: 288 добавлений и 130 удалений

Просмотреть файл

@ -4,6 +4,8 @@ import {
getServerOrigin, getServerOrigin,
getFeatureFlags getFeatureFlags
} from '@/modules/shared/helpers/envHelper' } from '@/modules/shared/helpers/envHelper'
import { ServerScope } from '@speckle/shared'
import { Merge } from 'type-fest'
export enum DefaultAppIds { export enum DefaultAppIds {
Web = 'spklwebapp', Web = 'spklwebapp',
@ -150,3 +152,10 @@ export function getDefaultApps() {
export function getDefaultApp({ id }: { id: string }) { export function getDefaultApp({ id }: { id: string }) {
return defaultApps.find((app) => app.id === id) || null return defaultApps.find((app) => app.id === id) || null
} }
export type DefaultApp = (typeof defaultApps)[number]
/**
* Some workflows need 'all' unwrapped into the actual scopes
*/
export type DefaultAppWithUnwrappedScopes = Merge<DefaultApp, { scopes: ServerScope[] }>

Просмотреть файл

@ -0,0 +1,16 @@
import { DefaultAppWithUnwrappedScopes } from '@/modules/auth/defaultApps'
import { FullServerApp } from '@/modules/auth/domain/types'
import { ScopeRecord } from '@/modules/auth/helpers/types'
export type GetApp = (params: { id: string }) => Promise<FullServerApp | null>
export type GetAllScopes = () => Promise<ScopeRecord[]>
export type RegisterDefaultApp = (app: DefaultAppWithUnwrappedScopes) => Promise<void>
export type UpdateDefaultApp = (
app: DefaultAppWithUnwrappedScopes,
existingApp: FullServerApp
) => Promise<void>
export type InitializeDefaultApps = () => Promise<void>

Просмотреть файл

@ -0,0 +1,7 @@
import { ScopeRecord } from '@/modules/auth/helpers/types'
import { ServerAppRecord, UserRecord } from '@/modules/core/helpers/types'
export type FullServerApp = ServerAppRecord & {
scopes: ScopeRecord[]
author: Pick<UserRecord, 'id' | 'name' | 'avatar'>
}

Просмотреть файл

@ -1,6 +1,5 @@
const { ForbiddenError } = require('@/modules/shared/errors') const { ForbiddenError } = require('@/modules/shared/errors')
const { const {
getApp,
getAllPublicApps, getAllPublicApps,
getAllAppsCreatedByUser, getAllAppsCreatedByUser,
getAllAppsAuthorizedByUser, getAllAppsAuthorizedByUser,
@ -10,6 +9,10 @@ const {
revokeExistingAppCredentialsForUser revokeExistingAppCredentialsForUser
} = require('../../services/apps') } = require('../../services/apps')
const { Roles } = require('@speckle/shared') const { Roles } = require('@speckle/shared')
const { getAppFactory } = require('@/modules/auth/repositories/apps')
const { db } = require('@/db/knex')
const getApp = getAppFactory({ db })
module.exports = { module.exports = {
Query: { Query: {

Просмотреть файл

@ -1,4 +1,4 @@
import { MaybeAsync } from '@speckle/shared' import { MaybeAsync, ServerScope } from '@speckle/shared'
import type { Express, RequestHandler } from 'express' import type { Express, RequestHandler } from 'express'
import type { Session, SessionData } from 'express-session' import type { Session, SessionData } from 'express-session'
import type { TokenSet, UserinfoResponse } from 'openid-client' import type { TokenSet, UserinfoResponse } from 'openid-client'
@ -51,9 +51,26 @@ export type AuthRequestData = {
authRedirectPath?: string authRedirectPath?: string
} }
export type ScopeRecord = {
name: ServerScope
description: string
public: boolean
}
export type ServerAppsScopesRecord = { export type ServerAppsScopesRecord = {
appId: string appId: string
scopeName: string scopeName: ServerScope
}
export type UserServerAppTokenRecord = {
userId: string
appId: string
tokenId: string
}
export type TokenScopeRecord = {
tokenId: string
scopeName: ServerScope
} }
export type AuthStrategyMetadata = { export type AuthStrategyMetadata = {

Просмотреть файл

@ -2,6 +2,20 @@
const { registerOrUpdateScopeFactory } = require('@/modules/shared/repositories/scopes') const { registerOrUpdateScopeFactory } = require('@/modules/shared/repositories/scopes')
const { moduleLogger } = require('@/logging/logging') const { moduleLogger } = require('@/logging/logging')
const db = require('@/db/knex') const db = require('@/db/knex')
const { initializeDefaultAppsFactory } = require('@/modules/auth/services/serverApps')
const {
getAllScopesFactory,
getAppFactory,
updateDefaultAppFactory,
registerDefaultAppFactory
} = require('@/modules/auth/repositories/apps')
const initializeDefaultApps = initializeDefaultAppsFactory({
getAllScopes: getAllScopesFactory({ db }),
getApp: getAppFactory({ db }),
updateDefaultApp: updateDefaultAppFactory({ db }),
registerDefaultApp: registerDefaultAppFactory({ db })
})
exports.init = async (app) => { exports.init = async (app) => {
moduleLogger.info('🔑 Init auth module') moduleLogger.info('🔑 Init auth module')
@ -23,5 +37,5 @@ exports.init = async (app) => {
exports.finalize = async () => { exports.finalize = async () => {
// Note: we're registering the default apps last as we want to ensure that all // Note: we're registering the default apps last as we want to ensure that all
// scopes have been registered by any other modules. // scopes have been registered by any other modules.
await require('./manageDefaultApps')() await initializeDefaultApps()
} }

Просмотреть файл

@ -1,100 +0,0 @@
'use strict'
const knex = require('@/db/knex')
const Scopes = () => knex('scopes')
const Apps = () => knex('server_apps')
const AppScopes = () => knex('server_apps_scopes')
const { getApp } = require('@/modules/auth/services/apps')
const { difference } = require('lodash')
const { moduleLogger } = require('@/logging/logging')
const { getDefaultApps } = require('@/modules/auth/defaultApps')
let allScopes = []
module.exports = async () => {
allScopes = await Scopes().select('*')
// Note: shallow cloning of app objs so as to not interfere with the original objects.
await Promise.all(getDefaultApps().map((app) => registerOrUpdateApp({ ...app })))
}
async function registerOrUpdateApp(app) {
if (app.scopes && app.scopes === 'all') {
// let scopes = await Scopes( ).select( '*' )
// logger.debug( allScopes.length )
app.scopes = allScopes.map((s) => s.name)
}
const existingApp = await getApp({ id: app.id })
if (existingApp) {
updateDefaultApp(app, existingApp)
} else {
await registerDefaultApp(app)
}
}
async function registerDefaultApp(app) {
const scopes = app.scopes.map((s) => ({ appId: app.id, scopeName: s }))
delete app.scopes
await Apps().insert(app)
await AppScopes().insert(scopes)
}
async function updateDefaultApp(app, existingApp) {
const existingAppScopes = existingApp.scopes.map((s) => s.name)
const newScopes = difference(app.scopes, existingAppScopes)
const removedScopes = difference(existingAppScopes, app.scopes)
let affectedTokenIds = []
if (newScopes.length || removedScopes.length) {
moduleLogger.info(`🔑 Updating default app ${app.name}`)
affectedTokenIds = await knex('user_server_app_tokens')
.where({ appId: app.id })
.pluck('tokenId')
}
// the internal code block makes sure if an error occurred, the trx gets rolled back
await knex.transaction(async (trx) => {
// add new scopes to the app
if (newScopes.length)
await AppScopes()
.insert(newScopes.map((s) => ({ appId: app.id, scopeName: s })))
.transacting(trx)
// remove scopes from the app
if (removedScopes.length)
await AppScopes()
.where({ appId: app.id })
.whereIn('scopeName', removedScopes)
.delete()
.transacting(trx)
//update user tokens with scope changes
if (affectedTokenIds.length)
await Promise.all(
affectedTokenIds.map(async (tokenId) => {
if (newScopes.length)
await knex('token_scopes')
.insert(newScopes.map((s) => ({ tokenId, scopeName: s })))
.transacting(trx)
if (removedScopes.length)
await knex('token_scopes')
.where({ tokenId })
.whereIn('scopeName', removedScopes)
.delete()
.transacting(trx)
})
)
delete app.scopes
// not writing the redirect url to the DB anymore
// it will be patched on an application level from the default app definitions
delete app.redirectUrl
await Apps().where({ id: app.id }).update(app).transacting(trx)
})
}
// this is exported to be able to test the retention of permissions
module.exports.updateDefaultApp = updateDefaultApp

Просмотреть файл

@ -0,0 +1,154 @@
import { moduleLogger } from '@/logging/logging'
import { getDefaultApp } from '@/modules/auth/defaultApps'
import {
GetAllScopes,
GetApp,
RegisterDefaultApp,
UpdateDefaultApp
} from '@/modules/auth/domain/operations'
import {
ScopeRecord,
ServerAppsScopesRecord,
TokenScopeRecord,
UserServerAppTokenRecord
} from '@/modules/auth/helpers/types'
import {
Scopes,
ServerApps,
ServerAppsScopes,
TokenScopes,
Users,
UserServerAppTokens
} from '@/modules/core/dbSchema'
import { ServerAppRecord, UserRecord } from '@/modules/core/helpers/types'
import { Knex } from 'knex'
import { difference, omit } from 'lodash'
const tables = {
serverApps: (db: Knex) => db<ServerAppRecord>(ServerApps.name),
scopes: (db: Knex) => db<ScopeRecord>(Scopes.name),
serverAppsScopes: (db: Knex) => db<ServerAppsScopesRecord>(ServerAppsScopes.name),
users: (db: Knex) => db<UserRecord>(Users.name),
userServerAppTokens: (db: Knex) =>
db<UserServerAppTokenRecord>(UserServerAppTokens.name),
tokenScopes: (db: Knex) => db<TokenScopeRecord>(TokenScopes.name)
}
const getAppRedirectUrl = (app: ServerAppRecord) => {
const defaultApp = getDefaultApp({ id: app.id })
return defaultApp ? defaultApp.redirectUrl : app.redirectUrl
}
export const getAppFactory =
(deps: { db: Knex }): GetApp =>
async (params) => {
const { id } = params
const allScopes = await getAllScopesFactory(deps)()
const app = await tables.serverApps(deps.db).select('*').where({ id }).first()
if (!app) return null
const appScopeNames = (
await tables.serverAppsScopes(deps.db).select('scopeName').where({ appId: id })
).map((s) => s.scopeName)
const appScopes = allScopes.filter(
(scope) => appScopeNames.indexOf(scope.name) !== -1
)
const appAuthor = await tables
.users(deps.db)
.select('id', 'name', 'avatar')
.where({ id: app.authorId })
.first()
return {
...app,
scopes: appScopes,
author: appAuthor!,
redirectUrl: getAppRedirectUrl(app)
}
}
export const getAllScopesFactory =
(deps: { db: Knex }): GetAllScopes =>
async () => {
return tables.scopes(deps.db).select('*')
}
export const registerDefaultAppFactory =
(deps: { db: Knex }): RegisterDefaultApp =>
async (app) => {
const scopes = app.scopes.map((s) => ({ appId: app.id, scopeName: s }))
await tables.serverApps(deps.db).insert(omit(app, 'scopes'))
await tables.serverAppsScopes(deps.db).insert(scopes)
}
export const updateDefaultAppFactory =
(deps: { db: Knex }): UpdateDefaultApp =>
async (app, existingApp) => {
const { db: knex } = deps
const existingAppScopes = existingApp.scopes.map((s) => s.name)
const newScopes = difference(app.scopes, existingAppScopes)
const removedScopes = difference(existingAppScopes, app.scopes)
let affectedTokenIds: string[] = []
if (newScopes.length || removedScopes.length) {
moduleLogger.info(`🔑 Updating default app ${app.name}`)
affectedTokenIds = await tables
.userServerAppTokens(knex)
.where({ appId: app.id })
.pluck('tokenId')
}
// the internal code block makes sure if an error occurred, the trx gets rolled back
await knex.transaction(async (trx) => {
// add new scopes to the app
if (newScopes.length)
await tables
.serverAppsScopes(knex)
.insert(newScopes.map((s) => ({ appId: app.id, scopeName: s })))
.transacting(trx)
// remove scopes from the app
if (removedScopes.length)
await tables
.serverAppsScopes(knex)
.where({ appId: app.id })
.whereIn('scopeName', removedScopes)
.delete()
.transacting(trx)
//update user tokens with scope changes
if (affectedTokenIds.length)
await Promise.all(
affectedTokenIds.map(async (tokenId) => {
if (newScopes.length)
await tables
.tokenScopes(knex)
.insert(newScopes.map((s) => ({ tokenId, scopeName: s })))
.transacting(trx)
if (removedScopes.length)
await tables
.tokenScopes(knex)
.where({ tokenId })
.whereIn('scopeName', removedScopes)
.delete()
.transacting(trx)
})
)
// not writing the redirect url to the DB anymore
// it will be patched on an application level from the default app definitions
await tables
.serverApps(knex)
.where({ id: app.id })
.update(omit(app, ['scopes', 'redirectUrl']))
.transacting(trx)
})
}

Просмотреть файл

@ -1,7 +1,6 @@
'use strict' 'use strict'
const cors = require('cors') const cors = require('cors')
const { const {
getApp,
createAuthorizationCode, createAuthorizationCode,
createAppTokenFromAccessCode, createAppTokenFromAccessCode,
refreshAppToken refreshAppToken
@ -12,6 +11,8 @@ const { validateScopes } = require(`@/modules/shared`)
const { InvalidAccessCodeRequestError } = require('@/modules/auth/errors') const { InvalidAccessCodeRequestError } = require('@/modules/auth/errors')
const { Scopes } = require('@speckle/shared') const { Scopes } = require('@speckle/shared')
const { ForbiddenError } = require('@/modules/shared/errors') const { ForbiddenError } = require('@/modules/shared/errors')
const { getAppFactory } = require('@/modules/auth/repositories/apps')
const { db } = require('@/db/knex')
// TODO: Secure these endpoints! // TODO: Secure these endpoints!
module.exports = (app) => { module.exports = (app) => {
@ -21,6 +22,7 @@ module.exports = (app) => {
*/ */
app.get('/auth/accesscode', async (req, res) => { app.get('/auth/accesscode', async (req, res) => {
try { try {
const getApp = getAppFactory({ db })
const preventRedirect = !!req.query.preventRedirect const preventRedirect = !!req.query.preventRedirect
const appId = req.query.appId const appId = req.query.appId
const app = await getApp({ id: appId }) const app = await getApp({ id: appId })

Просмотреть файл

@ -6,11 +6,10 @@ const knex = require(`@/db/knex`)
const { createBareToken, createAppToken } = require(`@/modules/core/services/tokens`) const { createBareToken, createAppToken } = require(`@/modules/core/services/tokens`)
const { logger } = require('@/logging/logging') const { logger } = require('@/logging/logging')
const { getDefaultApp } = require('@/modules/auth/defaultApps') const { getDefaultApp } = require('@/modules/auth/defaultApps')
const Users = () => knex('users') const { getAppFactory } = require('@/modules/auth/repositories/apps')
const ApiTokens = () => knex('api_tokens') const ApiTokens = () => knex('api_tokens')
const ServerApps = () => knex('server_apps') const ServerApps = () => knex('server_apps')
const ServerAppsScopes = () => knex('server_apps_scopes') const ServerAppsScopes = () => knex('server_apps_scopes')
const Scopes = () => knex('scopes')
const AuthorizationCodes = () => knex('authorization_codes') const AuthorizationCodes = () => knex('authorization_codes')
const RefreshTokens = () => knex('refresh_tokens') const RefreshTokens = () => knex('refresh_tokens')
@ -22,25 +21,6 @@ const addDefaultAppOverrides = (app) => {
} }
module.exports = { module.exports = {
async getApp({ id }) {
const allScopes = await Scopes().select('*')
const app = await ServerApps().select('*').where({ id }).first()
if (!app) return null
const appScopeNames = (
await ServerAppsScopes().select('scopeName').where({ appId: id })
).map((s) => s.scopeName)
app.scopes = allScopes.filter((scope) => appScopeNames.indexOf(scope.name) !== -1)
app.author = await Users()
.select('id', 'name', 'avatar')
.where({ id: app.authorId })
.first()
return addDefaultAppOverrides(app)
},
async getAllPublicApps() { async getAllPublicApps() {
const apps = await ServerApps() const apps = await ServerApps()
.select( .select(
@ -288,8 +268,8 @@ module.exports = {
const valid = await bcrypt.compare(refreshTokenContent, refreshTokenDb.tokenDigest) const valid = await bcrypt.compare(refreshTokenContent, refreshTokenDb.tokenDigest)
if (!valid) throw new Error('Invalid token') // sneky hackstors if (!valid) throw new Error('Invalid token') // sneky hackstors
const app = await module.exports.getApp({ id: appId }) const app = await getAppFactory({ db: knex })({ id: appId })
if (app.secret !== appSecret) throw new Error('Invalid request') if (!app || app.secret !== appSecret) throw new Error('Invalid request')
// Create the new token // Create the new token
const appToken = await createAppToken({ const appToken = await createAppToken({

Просмотреть файл

@ -0,0 +1,51 @@
import { getDefaultApps } from '@/modules/auth/defaultApps'
import {
GetAllScopes,
GetApp,
InitializeDefaultApps,
RegisterDefaultApp,
UpdateDefaultApp
} from '@/modules/auth/domain/operations'
import { ScopeRecord } from '@/modules/auth/helpers/types'
import { ServerScope } from '@speckle/shared'
/**
* Cached all scopes. Caching occurs on first initializeDefaultApps() call
*/
let allScopes: ScopeRecord[] = []
export const initializeDefaultAppsFactory =
(deps: {
getAllScopes: GetAllScopes
getApp: GetApp
updateDefaultApp: UpdateDefaultApp
registerDefaultApp: RegisterDefaultApp
}): InitializeDefaultApps =>
async () => {
allScopes = await deps.getAllScopes()
await Promise.all(
getDefaultApps().map(async (app) => {
const scopes =
app?.scopes === 'all'
? allScopes.map((s) => s.name)
: (app.scopes as ServerScope[])
const existingApp = await deps.getApp({ id: app.id })
if (existingApp) {
await deps.updateDefaultApp(
{
...app,
scopes
},
existingApp
)
} else {
await deps.registerDefaultApp({
...app,
scopes
})
}
})
)
}

Просмотреть файл

@ -5,7 +5,6 @@ const { createUser } = require(`@/modules/core/services/users`)
const { validateToken } = require(`@/modules/core/services/tokens`) const { validateToken } = require(`@/modules/core/services/tokens`)
const { beforeEachContext } = require(`@/test/hooks`) const { beforeEachContext } = require(`@/test/hooks`)
const { const {
getApp,
getAllPublicApps, getAllPublicApps,
createApp, createApp,
updateApp, updateApp,
@ -17,9 +16,15 @@ const {
} = require('../services/apps') } = require('../services/apps')
const { Scopes } = require('@/modules/core/helpers/mainConstants') const { Scopes } = require('@/modules/core/helpers/mainConstants')
const { updateDefaultApp } = require('@/modules/auth/manageDefaultApps')
const knex = require('@/db/knex') const knex = require('@/db/knex')
const cryptoRandomString = require('crypto-random-string') const cryptoRandomString = require('crypto-random-string')
const {
getAppFactory,
updateDefaultAppFactory
} = require('@/modules/auth/repositories/apps')
const getApp = getAppFactory({ db: knex })
const updateDefaultApp = updateDefaultAppFactory({ db: knex })
describe('Services @apps-services', () => { describe('Services @apps-services', () => {
const actor = { const actor = {