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
This commit is contained in:
Philip Korsholm authored and GitHub committed 2024-01-29 17:42:42 +08:00
1 parent d1c18a3090
commit a41aad4bea
10 files changed
+148 -47

No files matched your search

@@ -78,5 +78,19 @@ describe("AuthenticationModuleService - AuthProvider", () => {
"AuthenticationProvider with for provider: notRegistered wasn't registered in the module. Have you configured your options correctly?" "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`
)
})
}) })
}) })
@@ -73,6 +73,7 @@ describe("AuthenticationModuleService - AuthProvider", () => {
email: "test@test.com", email: "test@test.com",
password: password, password: password,
}, },
scope: "store",
}) })
expect(res).toEqual({ expect(res).toEqual({
@@ -91,6 +92,7 @@ describe("AuthenticationModuleService - AuthProvider", () => {
const res = await service.authenticate("usernamePassword", { const res = await service.authenticate("usernamePassword", {
body: { email: "test@test.com" }, body: { email: "test@test.com" },
scope: "store",
}) })
expect(res).toEqual({ expect(res).toEqual({
@@ -104,6 +106,7 @@ describe("AuthenticationModuleService - AuthProvider", () => {
const res = await service.authenticate("usernamePassword", { const res = await service.authenticate("usernamePassword", {
body: { password: "supersecret" }, body: { password: "supersecret" },
scope: "store",
}) })
expect(res).toEqual({ expect(res).toEqual({
@@ -136,6 +139,7 @@ describe("AuthenticationModuleService - AuthProvider", () => {
email: "test@test.com", email: "test@test.com",
password: "password", password: "password",
}, },
scope: "store",
}) })
expect(res).toEqual({ expect(res).toEqual({
@@ -10,6 +10,15 @@ export function getInitModuleConfig() {
schema: process.env.MEDUSA_AUTHENTICATION_DB_SCHEMA, schema: process.env.MEDUSA_AUTHENTICATION_DB_SCHEMA,
}, },
}, },
providers: [
{
name: "usernamePassword",
scopes: {
admin: {},
store: {},
},
},
],
} }
const injectedDependencies = {} const injectedDependencies = {}
@@ -1,21 +1,38 @@
import * as defaultProviders from "@providers" import * as defaultProviders from "@providers"
import { import {
asClass,
AwilixContainer, AwilixContainer,
ClassOrFunctionReturning, ClassOrFunctionReturning,
Constructor, Constructor,
Resolver, Resolver,
asClass,
} from "awilix" } from "awilix"
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/types" import {
AuthModuleProviderConfig,
AuthProviderScope,
LoaderOptions,
ModulesSdkTypes,
} from "@medusajs/types"
type AuthModuleProviders = {
providers: AuthModuleProviderConfig[]
}
export default async ({ export default async ({
container, container,
options,
}: LoaderOptions< }: LoaderOptions<
(
| ModulesSdkTypes.ModuleServiceInitializeOptions | ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) &
AuthModuleProviders
>): Promise<void> => { >): Promise<void> => {
// 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 // TODO: implement plugin provider registration
// } // }
@@ -25,20 +42,30 @@ export default async ({
container.register({ container.register({
[`auth_provider_${provider.PROVIDER}`]: asClass( [`auth_provider_${provider.PROVIDER}`]: asClass(
provider as Constructor<any> provider as Constructor<any>
).singleton(), )
.singleton()
.inject(() => ({ scopes: providerMap.get(provider.PROVIDER) ?? {} })),
}) })
} }
container.register({ container.register({
[`auth_providers`]: asArray(providersToLoad), [`auth_providers`]: asArray(providersToLoad, providerMap),
}) })
} }
function asArray( function asArray(
resolvers: (ClassOrFunctionReturning<unknown> | Resolver<unknown>)[] resolvers: (ClassOrFunctionReturning<unknown> | Resolver<unknown>)[],
providerScopeMap: Map<string, Record<string, AuthProviderScope>>
): { resolve: (container: AwilixContainer) => unknown[] } { ): { resolve: (container: AwilixContainer) => unknown[] } {
return { return {
resolve: (container: AwilixContainer) => resolve: (container: AwilixContainer) =>
resolvers.map((resolver) => container.build(resolver)), resolvers.map((resolver) =>
asClass(resolver as Constructor<any>)
.inject(() => ({
// @ts-ignore
scopes: providerScopeMap.get(resolver.PROVIDER) ?? {},
}))
.resolve(container)
),
} }
} }
+29 -23
View File
@@ -4,9 +4,11 @@ import {
} from "@medusajs/utils" } from "@medusajs/utils"
import { AuthProviderService, AuthUserService } from "@services" import { AuthProviderService, AuthUserService } from "@services"
import jwt, { JwtPayload } from "jsonwebtoken" import jwt, { JwtPayload } from "jsonwebtoken"
import {
import { AuthProvider } from "@models" AuthenticationInput,
import { AuthenticationResponse } from "@medusajs/types" AuthenticationResponse,
AuthProviderScope,
} from "@medusajs/types"
import { AuthorizationCode } from "simple-oauth2" import { AuthorizationCode } from "simple-oauth2"
import url from "url" import url from "url"
@@ -15,14 +17,6 @@ type InjectedDependencies = {
authProviderService: AuthProviderService authProviderService: AuthProviderService
} }
type AuthenticationInput = {
connection: { encrypted: boolean }
url: string
headers: { host: string }
query: Record<string, string>
body: Record<string, string>
}
type ProviderConfig = { type ProviderConfig = {
clientID: string clientID: string
clientSecret: string clientSecret: string
@@ -37,7 +31,7 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider {
protected readonly authProviderService_: AuthProviderService protected readonly authProviderService_: AuthProviderService
constructor({ authUserService, authProviderService }: InjectedDependencies) { constructor({ authUserService, authProviderService }: InjectedDependencies) {
super() super(arguments[0])
this.authUserSerivce_ = authUserService this.authUserSerivce_ = authUserService
this.authProviderService_ = authProviderService this.authProviderService_ = authProviderService
@@ -84,11 +78,11 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider {
const code = req.query?.code ?? req.body?.code const code = req.query?.code ?? req.body?.code
return await this.validateCallbackToken(code, config) return await this.validateCallbackToken(code, req.scope, config)
} }
// abstractable // abstractable
async verify_(refreshToken: string) { async verify_(refreshToken: string, scope: string) {
const jwtData = (await jwt.decode(refreshToken, { const jwtData = (await jwt.decode(refreshToken, {
complete: true, complete: true,
})) as JwtPayload })) as JwtPayload
@@ -108,6 +102,7 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider {
entity_id, entity_id,
provider_id: GoogleProvider.PROVIDER, provider_id: GoogleProvider.PROVIDER,
user_metadata: jwtData!.payload, user_metadata: jwtData!.payload,
app_metadata: { scope },
}, },
]) ])
} else { } else {
@@ -121,6 +116,7 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider {
// abstractable // abstractable
private async validateCallbackToken( private async validateCallbackToken(
code: string, code: string,
scope: string,
{ clientID, callbackURL, clientSecret }: ProviderConfig { clientID, callbackURL, clientSecret }: ProviderConfig
) { ) {
const client = this.getAuthorizationCodeHandler({ clientID, clientSecret }) const client = this.getAuthorizationCodeHandler({ clientID, clientSecret })
@@ -133,24 +129,34 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider {
try { try {
const accessToken = await client.getToken(tokenParams) 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) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
} }
} }
private async validateConfig(config: Partial<ProviderConfig>) { private getConfigFromScope(config: AuthProviderScope): ProviderConfig {
if (!config.clientID) { const providerConfig: Partial<ProviderConfig> = {}
if (config.clientId) {
providerConfig.clientID = config.clientId
} else {
throw new Error("Google clientID is required") 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") 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") throw new Error("Google callbackUrl is required")
} }
return providerConfig as ProviderConfig
} }
private originalURL(req: AuthenticationInput) { private originalURL(req: AuthenticationInput) {
@@ -165,11 +171,11 @@ class GoogleProvider extends AbstractAuthenticationModuleProvider {
private async getProviderConfig( private async getProviderConfig(
req: AuthenticationInput req: AuthenticationInput
): Promise<ProviderConfig> { ): Promise<ProviderConfig> {
const { config } = (await this.authProviderService_.retrieve( await this.authProviderService_.retrieve(GoogleProvider.PROVIDER)
GoogleProvider.PROVIDER
)) as AuthProvider & { config: ProviderConfig }
this.validateConfig(config || {}) const scopeConfig = this.scopes_[req.scope]
const config = this.getConfigFromScope(scopeConfig)
const { callbackURL } = config const { callbackURL } = config
@@ -1,7 +1,7 @@
import { AbstractAuthenticationModuleProvider, isString } from "@medusajs/utils" import { AbstractAuthenticationModuleProvider, isString } from "@medusajs/utils"
import { AuthUserService } from "@services" import { AuthUserService } from "@services"
import { AuthenticationResponse } from "@medusajs/types" import { AuthenticationInput, AuthenticationResponse } from "@medusajs/types"
import Scrypt from "scrypt-kdf" import Scrypt from "scrypt-kdf"
class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider { class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider {
@@ -10,14 +10,14 @@ class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider {
protected readonly authUserSerivce_: AuthUserService protected readonly authUserSerivce_: AuthUserService
constructor({ authUserService: AuthUserService }) { constructor({ authUserService }: { authUserService: AuthUserService }) {
super() super(arguments[0])
this.authUserSerivce_ = AuthUserService this.authUserSerivce_ = authUserService
} }
async authenticate( async authenticate(
userData: Record<string, any> userData: AuthenticationInput
): Promise<AuthenticationResponse> { ): Promise<AuthenticationResponse> {
const { email, password } = userData.body const { email, password } = userData.body
@@ -43,7 +43,7 @@ class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider {
const password_hash = authUser.provider_metadata?.password const password_hash = authUser.provider_metadata?.password
if (isString(password_hash)) { 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) const success = await Scrypt.verify(buf, password)
@@ -1,4 +1,5 @@
import { import {
AuthenticationInput,
AuthenticationResponse, AuthenticationResponse,
AuthenticationTypes, AuthenticationTypes,
Context, Context,
@@ -353,7 +354,8 @@ export default class AuthenticationModuleService<
} }
protected getRegisteredAuthenticationProvider( protected getRegisteredAuthenticationProvider(
provider: string provider: string,
{ scope }: AuthenticationInput
): AbstractAuthenticationModuleProvider { ): AbstractAuthenticationModuleProvider {
let containerProvider: AbstractAuthenticationModuleProvider let containerProvider: AbstractAuthenticationModuleProvider
try { try {
@@ -365,18 +367,22 @@ export default class AuthenticationModuleService<
) )
} }
containerProvider.validateScope(scope)
return containerProvider return containerProvider
} }
async authenticate( async authenticate(
provider: string, provider: string,
authenticationData: Record<string, unknown> authenticationData: AuthenticationInput
): Promise<AuthenticationResponse> { ): Promise<AuthenticationResponse> {
try { try {
await this.retrieveAuthProvider(provider, {}) await this.retrieveAuthProvider(provider, {})
const registeredProvider = const registeredProvider = this.getRegisteredAuthenticationProvider(
this.getRegisteredAuthenticationProvider(provider) provider,
authenticationData
)
return await registeredProvider.authenticate(authenticationData) return await registeredProvider.authenticate(authenticationData)
} catch (error) { } catch (error) {
@@ -386,13 +392,15 @@ export default class AuthenticationModuleService<
async validateCallback( async validateCallback(
provider: string, provider: string,
authenticationData: Record<string, unknown> authenticationData: AuthenticationInput
): Promise<AuthenticationResponse> { ): Promise<AuthenticationResponse> {
try { try {
await this.retrieveAuthProvider(provider, {}) await this.retrieveAuthProvider(provider, {})
const registeredProvider = const registeredProvider = this.getRegisteredAuthenticationProvider(
this.getRegisteredAuthenticationProvider(provider) provider,
authenticationData
)
return await registeredProvider.validateCallback(authenticationData) return await registeredProvider.validateCallback(authenticationData)
} catch (error) { } catch (error) {
@@ -2,4 +2,21 @@ export type AuthenticationResponse = {
success: boolean success: boolean
authUser?: any authUser?: any
error?: string error?: string
location?: string
}
export type AuthModuleProviderConfig = {
name: string
scopes: Record<string, AuthProviderScope>
}
export type AuthProviderScope = Record<string, string>
export type AuthenticationInput = {
connection: { encrypted: boolean }
url: string
headers: Record<string, string>
query: Record<string, string>
body: Record<string, string>
scope: string
} }
+4 -3
View File
@@ -1,7 +1,8 @@
import { import {
AuthenticationInput,
AuthenticationResponse,
AuthProviderDTO, AuthProviderDTO,
AuthUserDTO, AuthUserDTO,
AuthenticationResponse,
CreateAuthProviderDTO, CreateAuthProviderDTO,
CreateAuthUserDTO, CreateAuthUserDTO,
FilterableAuthProviderProps, FilterableAuthProviderProps,
@@ -17,12 +18,12 @@ import { IModuleService } from "../modules-sdk"
export interface IAuthenticationModuleService extends IModuleService { export interface IAuthenticationModuleService extends IModuleService {
authenticate( authenticate(
provider: string, provider: string,
providerData: Record<string, unknown> providerData: AuthenticationInput
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>
validateCallback( validateCallback(
provider: string, provider: string,
providerData: Record<string, unknown> providerData: AuthenticationInput
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>
retrieveAuthProvider( retrieveAuthProvider(
@@ -1,8 +1,10 @@
import { AuthenticationResponse } from "@medusajs/types" import { AuthenticationResponse, AuthProviderScope } from "@medusajs/types"
import { MedusaError } from "../common"
export abstract class AbstractAuthenticationModuleProvider { export abstract class AbstractAuthenticationModuleProvider {
public static PROVIDER: string public static PROVIDER: string
public static DISPLAY_NAME: string public static DISPLAY_NAME: string
protected readonly scopes_: Record<string, AuthProviderScope>
public get provider() { public get provider() {
return (this.constructor as Function & { PROVIDER: string }).PROVIDER return (this.constructor as Function & { PROVIDER: string }).PROVIDER
@@ -13,6 +15,19 @@ export abstract class AbstractAuthenticationModuleProvider {
.DISPLAY_NAME .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( abstract authenticate(
data: Record<string, unknown> data: Record<string, unknown>
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>