From a41aad4beac905e4f8e885c1c6b45e24e3084490 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:42:42 +0800 Subject: [PATCH] feat(authentication, types, utils): Add Authentication provider scopes (#6228) * initial implementation * add test for invalid scope * get config from scope not db * assign config from scope * fix package.json * optional providers * make providers options * update type --- .../services/module/providers.spec.ts | 14 +++++ .../providers/username-password.spec.ts | 4 ++ .../utils/get-init-module-config.ts | 9 ++++ .../authentication/src/loaders/providers.ts | 45 ++++++++++++---- .../authentication/src/providers/google.ts | 52 +++++++++++-------- .../src/providers/username-password.ts | 12 ++--- .../src/services/authentication-module.ts | 22 +++++--- .../src/authentication/common/provider.ts | 17 ++++++ packages/types/src/authentication/service.ts | 7 +-- .../abstract-authentication-provider.ts | 17 +++++- 10 files changed, 150 insertions(+), 49 deletions(-) diff --git a/packages/authentication/integration-tests/__tests__/services/module/providers.spec.ts b/packages/authentication/integration-tests/__tests__/services/module/providers.spec.ts index 63a3af0817..957b1926f6 100644 --- a/packages/authentication/integration-tests/__tests__/services/module/providers.spec.ts +++ b/packages/authentication/integration-tests/__tests__/services/module/providers.spec.ts @@ -78,5 +78,19 @@ describe("AuthenticationModuleService - AuthProvider", () => { "AuthenticationProvider with for provider: notRegistered wasn't registered in the module. Have you configured your options correctly?" ) }) + + it("fails to authenticate using a valid provider with an invalid scope", async () => { + const { success, error } = await service.authenticate( + "usernamePassword", + { + scope: "non-existing", + } + ) + + expect(success).toBe(false) + expect(error).toEqual( + `Scope "non-existing" is not valid for provider usernamePassword` + ) + }) }) }) diff --git a/packages/authentication/integration-tests/__tests__/services/providers/username-password.spec.ts b/packages/authentication/integration-tests/__tests__/services/providers/username-password.spec.ts index 3f5ec7da6d..388c6b0f41 100644 --- a/packages/authentication/integration-tests/__tests__/services/providers/username-password.spec.ts +++ b/packages/authentication/integration-tests/__tests__/services/providers/username-password.spec.ts @@ -73,6 +73,7 @@ describe("AuthenticationModuleService - AuthProvider", () => { email: "test@test.com", password: password, }, + scope: "store", }) expect(res).toEqual({ @@ -91,6 +92,7 @@ describe("AuthenticationModuleService - AuthProvider", () => { const res = await service.authenticate("usernamePassword", { body: { email: "test@test.com" }, + scope: "store", }) expect(res).toEqual({ @@ -104,6 +106,7 @@ describe("AuthenticationModuleService - AuthProvider", () => { const res = await service.authenticate("usernamePassword", { body: { password: "supersecret" }, + scope: "store", }) expect(res).toEqual({ @@ -136,6 +139,7 @@ describe("AuthenticationModuleService - AuthProvider", () => { email: "test@test.com", password: "password", }, + scope: "store", }) expect(res).toEqual({ diff --git a/packages/authentication/integration-tests/utils/get-init-module-config.ts b/packages/authentication/integration-tests/utils/get-init-module-config.ts index 5ba9ea8c9b..ba2ab6c81f 100644 --- a/packages/authentication/integration-tests/utils/get-init-module-config.ts +++ b/packages/authentication/integration-tests/utils/get-init-module-config.ts @@ -10,6 +10,15 @@ export function getInitModuleConfig() { schema: process.env.MEDUSA_AUTHENTICATION_DB_SCHEMA, }, }, + providers: [ + { + name: "usernamePassword", + scopes: { + admin: {}, + store: {}, + }, + }, + ], } const injectedDependencies = {} diff --git a/packages/authentication/src/loaders/providers.ts b/packages/authentication/src/loaders/providers.ts index 49cea33483..4f7c11048e 100644 --- a/packages/authentication/src/loaders/providers.ts +++ b/packages/authentication/src/loaders/providers.ts @@ -1,21 +1,38 @@ import * as defaultProviders from "@providers" import { + asClass, AwilixContainer, ClassOrFunctionReturning, Constructor, Resolver, - asClass, } from "awilix" -import { LoaderOptions, ModulesSdkTypes } from "@medusajs/types" +import { + AuthModuleProviderConfig, + AuthProviderScope, + LoaderOptions, + ModulesSdkTypes, +} from "@medusajs/types" + +type AuthModuleProviders = { + providers: AuthModuleProviderConfig[] +} export default async ({ container, + options, }: LoaderOptions< - | ModulesSdkTypes.ModuleServiceInitializeOptions - | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + ( + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + ) & + AuthModuleProviders >): Promise => { - // if(options.providers?.length) { + const providerMap = new Map( + options?.providers?.map((provider) => [provider.name, provider.scopes]) ?? + [] + ) + // if(options?.providers?.length) { // TODO: implement plugin provider registration // } @@ -25,20 +42,30 @@ export default async ({ container.register({ [`auth_provider_${provider.PROVIDER}`]: asClass( provider as Constructor - ).singleton(), + ) + .singleton() + .inject(() => ({ scopes: providerMap.get(provider.PROVIDER) ?? {} })), }) } container.register({ - [`auth_providers`]: asArray(providersToLoad), + [`auth_providers`]: asArray(providersToLoad, providerMap), }) } function asArray( - resolvers: (ClassOrFunctionReturning | Resolver)[] + resolvers: (ClassOrFunctionReturning | Resolver)[], + providerScopeMap: Map> ): { resolve: (container: AwilixContainer) => unknown[] } { return { resolve: (container: AwilixContainer) => - resolvers.map((resolver) => container.build(resolver)), + resolvers.map((resolver) => + asClass(resolver as Constructor) + .inject(() => ({ + // @ts-ignore + scopes: providerScopeMap.get(resolver.PROVIDER) ?? {}, + })) + .resolve(container) + ), } } diff --git a/packages/authentication/src/providers/google.ts b/packages/authentication/src/providers/google.ts index a1d7cd48bc..c27c9b3871 100644 --- a/packages/authentication/src/providers/google.ts +++ b/packages/authentication/src/providers/google.ts @@ -4,9 +4,11 @@ import { } from "@medusajs/utils" import { AuthProviderService, AuthUserService } from "@services" import jwt, { JwtPayload } from "jsonwebtoken" - -import { AuthProvider } from "@models" -import { AuthenticationResponse } from "@medusajs/types" +import { + AuthenticationInput, + AuthenticationResponse, + AuthProviderScope, +} from "@medusajs/types" import { AuthorizationCode } from "simple-oauth2" import url from "url" @@ -15,14 +17,6 @@ type InjectedDependencies = { authProviderService: AuthProviderService } -type AuthenticationInput = { - connection: { encrypted: boolean } - url: string - headers: { host: string } - query: Record - body: Record -} - type ProviderConfig = { clientID: string clientSecret: string @@ -37,7 +31,7 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider { protected readonly authProviderService_: AuthProviderService constructor({ authUserService, authProviderService }: InjectedDependencies) { - super() + super(arguments[0]) this.authUserSerivce_ = authUserService this.authProviderService_ = authProviderService @@ -84,11 +78,11 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider { const code = req.query?.code ?? req.body?.code - return await this.validateCallbackToken(code, config) + return await this.validateCallbackToken(code, req.scope, config) } // abstractable - async verify_(refreshToken: string) { + async verify_(refreshToken: string, scope: string) { const jwtData = (await jwt.decode(refreshToken, { complete: true, })) as JwtPayload @@ -108,6 +102,7 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider { entity_id, provider_id: GoogleProvider.PROVIDER, user_metadata: jwtData!.payload, + app_metadata: { scope }, }, ]) } else { @@ -121,6 +116,7 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider { // abstractable private async validateCallbackToken( code: string, + scope: string, { clientID, callbackURL, clientSecret }: ProviderConfig ) { const client = this.getAuthorizationCodeHandler({ clientID, clientSecret }) @@ -133,24 +129,34 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider { try { const accessToken = await client.getToken(tokenParams) - return await this.verify_(accessToken.token.id_token) + return await this.verify_(accessToken.token.id_token, scope) } catch (error) { return { success: false, error: error.message } } } - private async validateConfig(config: Partial) { - if (!config.clientID) { + private getConfigFromScope(config: AuthProviderScope): ProviderConfig { + const providerConfig: Partial = {} + + if (config.clientId) { + providerConfig.clientID = config.clientId + } else { throw new Error("Google clientID is required") } - if (!config.clientSecret) { + if (config.clientSecret) { + providerConfig.clientSecret = config.clientSecret + } else { throw new Error("Google clientSecret is required") } - if (!config.callbackURL) { + if (config.callbackURL) { + providerConfig.callbackURL = config.callbackUrl + } else { throw new Error("Google callbackUrl is required") } + + return providerConfig as ProviderConfig } private originalURL(req: AuthenticationInput) { @@ -165,11 +171,11 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider { private async getProviderConfig( req: AuthenticationInput ): Promise { - const { config } = (await this.authProviderService_.retrieve( - GoogleProvider.PROVIDER - )) as AuthProvider & { config: ProviderConfig } + await this.authProviderService_.retrieve(GoogleProvider.PROVIDER) - this.validateConfig(config || {}) + const scopeConfig = this.scopes_[req.scope] + + const config = this.getConfigFromScope(scopeConfig) const { callbackURL } = config diff --git a/packages/authentication/src/providers/username-password.ts b/packages/authentication/src/providers/username-password.ts index 905109fb92..54b276b4e8 100644 --- a/packages/authentication/src/providers/username-password.ts +++ b/packages/authentication/src/providers/username-password.ts @@ -1,7 +1,7 @@ import { AbstractAuthenticationModuleProvider, isString } from "@medusajs/utils" import { AuthUserService } from "@services" -import { AuthenticationResponse } from "@medusajs/types" +import { AuthenticationInput, AuthenticationResponse } from "@medusajs/types" import Scrypt from "scrypt-kdf" class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider { @@ -10,14 +10,14 @@ class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider { protected readonly authUserSerivce_: AuthUserService - constructor({ authUserService: AuthUserService }) { - super() + constructor({ authUserService }: { authUserService: AuthUserService }) { + super(arguments[0]) - this.authUserSerivce_ = AuthUserService + this.authUserSerivce_ = authUserService } async authenticate( - userData: Record + userData: AuthenticationInput ): Promise { const { email, password } = userData.body @@ -43,7 +43,7 @@ class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider { const password_hash = authUser.provider_metadata?.password if (isString(password_hash)) { - const buf = Buffer.from(password_hash, "base64") + const buf = Buffer.from(password_hash as string, "base64") const success = await Scrypt.verify(buf, password) diff --git a/packages/authentication/src/services/authentication-module.ts b/packages/authentication/src/services/authentication-module.ts index be6d1216ac..5cb604e929 100644 --- a/packages/authentication/src/services/authentication-module.ts +++ b/packages/authentication/src/services/authentication-module.ts @@ -1,4 +1,5 @@ import { + AuthenticationInput, AuthenticationResponse, AuthenticationTypes, Context, @@ -353,7 +354,8 @@ export default class AuthenticationModuleService< } protected getRegisteredAuthenticationProvider( - provider: string + provider: string, + { scope }: AuthenticationInput ): AbstractAuthenticationModuleProvider { let containerProvider: AbstractAuthenticationModuleProvider try { @@ -365,18 +367,22 @@ export default class AuthenticationModuleService< ) } + containerProvider.validateScope(scope) + return containerProvider } async authenticate( provider: string, - authenticationData: Record + authenticationData: AuthenticationInput ): Promise { try { await this.retrieveAuthProvider(provider, {}) - const registeredProvider = - this.getRegisteredAuthenticationProvider(provider) + const registeredProvider = this.getRegisteredAuthenticationProvider( + provider, + authenticationData + ) return await registeredProvider.authenticate(authenticationData) } catch (error) { @@ -386,13 +392,15 @@ export default class AuthenticationModuleService< async validateCallback( provider: string, - authenticationData: Record + authenticationData: AuthenticationInput ): Promise { try { await this.retrieveAuthProvider(provider, {}) - const registeredProvider = - this.getRegisteredAuthenticationProvider(provider) + const registeredProvider = this.getRegisteredAuthenticationProvider( + provider, + authenticationData + ) return await registeredProvider.validateCallback(authenticationData) } catch (error) { diff --git a/packages/types/src/authentication/common/provider.ts b/packages/types/src/authentication/common/provider.ts index aef4339630..a24fdc8f2f 100644 --- a/packages/types/src/authentication/common/provider.ts +++ b/packages/types/src/authentication/common/provider.ts @@ -2,4 +2,21 @@ export type AuthenticationResponse = { success: boolean authUser?: any error?: string + location?: string +} + +export type AuthModuleProviderConfig = { + name: string + scopes: Record +} + +export type AuthProviderScope = Record + +export type AuthenticationInput = { + connection: { encrypted: boolean } + url: string + headers: Record + query: Record + body: Record + scope: string } diff --git a/packages/types/src/authentication/service.ts b/packages/types/src/authentication/service.ts index bd2c8728e7..5d9b08d9ac 100644 --- a/packages/types/src/authentication/service.ts +++ b/packages/types/src/authentication/service.ts @@ -1,7 +1,8 @@ import { + AuthenticationInput, + AuthenticationResponse, AuthProviderDTO, AuthUserDTO, - AuthenticationResponse, CreateAuthProviderDTO, CreateAuthUserDTO, FilterableAuthProviderProps, @@ -17,12 +18,12 @@ import { IModuleService } from "../modules-sdk" export interface IAuthenticationModuleService extends IModuleService { authenticate( provider: string, - providerData: Record + providerData: AuthenticationInput ): Promise validateCallback( provider: string, - providerData: Record + providerData: AuthenticationInput ): Promise retrieveAuthProvider( diff --git a/packages/utils/src/authentication/abstract-authentication-provider.ts b/packages/utils/src/authentication/abstract-authentication-provider.ts index bfa21be4d8..925063bf02 100644 --- a/packages/utils/src/authentication/abstract-authentication-provider.ts +++ b/packages/utils/src/authentication/abstract-authentication-provider.ts @@ -1,8 +1,10 @@ -import { AuthenticationResponse } from "@medusajs/types" +import { AuthenticationResponse, AuthProviderScope } from "@medusajs/types" +import { MedusaError } from "../common" export abstract class AbstractAuthenticationModuleProvider { public static PROVIDER: string public static DISPLAY_NAME: string + protected readonly scopes_: Record public get provider() { return (this.constructor as Function & { PROVIDER: string }).PROVIDER @@ -13,6 +15,19 @@ export abstract class AbstractAuthenticationModuleProvider { .DISPLAY_NAME } + protected constructor({ scopes }) { + this.scopes_ = scopes + } + + public validateScope(scope) { + if (!this.scopes_[scope]) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + `Scope "${scope}" is not valid for provider ${this.provider}` + ) + } + } + abstract authenticate( data: Record ): Promise