Revamp the authentication setup (#7419)

* feat: Add email pass authentication provider package

* feat: Revamp auth module and remove concept of scope

* feat: Revamp the auth module to be more standardized in how providers are loaded

* feat: Switch from scope to actor type for authentication

* feat: Add support for per-actor auth methods

* feat: Add emailpass auth provider by default

* fix: Add back app_metadata in auth module
This commit is contained in:
Stevche Radevski
2024-05-23 20:56:40 +02:00
committed by GitHub
parent 7b0cfe3b77
commit 8a070d5d85
100 changed files with 991 additions and 1005 deletions

View File

@@ -1,21 +1,26 @@
import * as defaultProviders from "@providers"
import EmailPassProvider from "@medusajs/auth-emailpass"
import { LoaderOptions, ModulesSdkTypes, ModuleProvider } from "@medusajs/types"
import { Lifetime, asFunction, asValue } from "awilix"
import { moduleProviderLoader } from "@medusajs/modules-sdk"
import {
AuthModuleProviderConfig,
AuthProviderScope,
LoaderOptions,
ModulesSdkTypes,
} from "@medusajs/types"
import {
AwilixContainer,
ClassOrFunctionReturning,
Constructor,
Resolver,
asClass,
} from "awilix"
AuthIdentifiersRegistrationName,
AuthProviderRegistrationPrefix,
} from "@types"
type AuthModuleProviders = {
providers: AuthModuleProviderConfig[]
const registrationFn = async (klass, container, pluginOptions) => {
Object.entries(pluginOptions.config || []).map(([name, config]) => {
container.register({
[AuthProviderRegistrationPrefix + name]: asFunction(
(cradle) => new klass(cradle, config),
{
lifetime: klass.LIFE_TIME || Lifetime.SINGLETON,
}
),
})
container.registerAdd(AuthIdentifiersRegistrationName, asValue(name))
})
}
export default async ({
@@ -25,48 +30,28 @@ export default async ({
(
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) &
AuthModuleProviders
) & { providers: ModuleProvider[] }
>): Promise<void> => {
const providerMap = new Map(
options?.providers?.map((provider) => [provider.name, provider.scopes]) ??
[]
)
// TODO: Temporary settings used by the starter, remove once the auth module is updated
const isLegacyOptions =
options?.providers?.length && !!(options?.providers[0] as any)?.name
// if(options?.providers?.length) {
// TODO: implement plugin provider registration
// }
// Note: For now we want to inject some providers out of the box
const providerConfig = [
{
resolve: EmailPassProvider,
options: {
config: {
emailpass: {},
},
},
},
...(isLegacyOptions ? [] : options?.providers ?? []),
]
const providersToLoad = Object.values(defaultProviders)
for (const provider of providersToLoad) {
container.register({
[`auth_provider_${provider.PROVIDER}`]: asClass(
provider as Constructor<any>
)
.singleton()
.inject(() => ({ scopes: providerMap.get(provider.PROVIDER) ?? {} })),
})
}
container.register({
[`auth_providers`]: asArray(providersToLoad, providerMap),
await moduleProviderLoader({
container,
providers: providerConfig,
registerServiceFn: registrationFn,
})
}
function asArray(
resolvers: (ClassOrFunctionReturning<unknown> | Resolver<unknown>)[],
providerScopeMap: Map<string, Record<string, AuthProviderScope>>
): { resolve: (container: AwilixContainer) => unknown[] } {
return {
resolve: (container: AwilixContainer) =>
resolvers.map((resolver) =>
asClass(resolver as Constructor<any>)
.inject(() => ({
// @ts-ignore
scopes: providerScopeMap.get(resolver.PROVIDER) ?? {},
}))
.resolve(container)
),
}
}

View File

@@ -31,15 +31,6 @@
"nullable": false,
"mappedType": "text"
},
"scope": {
"name": "scope",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"user_metadata": {
"name": "user_metadata",
"type": "jsonb",
@@ -72,8 +63,8 @@
"schema": "public",
"indexes": [
{
"keyName": "IDX_auth_identity_provider_scope_entity_id",
"columnNames": ["provider", "scope", "entity_id"],
"keyName": "IDX_auth_identity_provider_entity_id",
"columnNames": ["provider", "entity_id"],
"composite": true,
"primary": false,
"unique": true

View File

@@ -1,16 +0,0 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240205025924 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table if not exists "auth_identity" ("id" text not null, "entity_id" text not null, "provider" text not null, "scope" text not null, "user_metadata" jsonb null, "app_metadata" jsonb not null, "provider_metadata" jsonb null, constraint "auth_identity_pkey" primary key ("id"));'
)
this.addSql(
'alter table "auth_identity" add constraint "IDX_auth_identity_provider_scope_entity_id" unique ("provider", "scope", "entity_id");'
)
}
async down(): Promise<void> {
this.addSql('drop table if exists "auth_identity" cascade;')
}
}

View File

@@ -0,0 +1,21 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240205025928 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table if not exists "auth_identity" ("id" text not null, "entity_id" text not null, "provider" text not null, "user_metadata" jsonb null, "app_metadata" jsonb null, "provider_metadata" jsonb null, constraint "auth_identity_pkey" primary key ("id"));'
)
this.addSql(
'alter table "auth_identity" add constraint "IDX_auth_identity_provider_entity_id" unique ("provider", "entity_id");'
)
this.addSql('alter table "auth_identity" drop column if exists "scope";')
this.addSql(
`alter table "auth_identity" alter column "app_metadata" drop not null;`
)
}
async down(): Promise<void> {
this.addSql('drop table if exists "auth_identity" cascade;')
}
}

View File

@@ -14,8 +14,8 @@ type OptionalFields = "provider_metadata" | "app_metadata" | "user_metadata"
@Entity()
@Unique({
properties: ["provider", "scope", "entity_id"],
name: "IDX_auth_identity_provider_scope_entity_id",
properties: ["provider", "entity_id"],
name: "IDX_auth_identity_provider_entity_id",
})
export default class AuthIdentity {
[OptionalProps]: OptionalFields
@@ -29,14 +29,11 @@ export default class AuthIdentity {
@Property({ columnType: "text" })
provider: string
@Property({ columnType: "text" })
scope: string
@Property({ columnType: "jsonb", nullable: true })
user_metadata: Record<string, unknown> | null
@Property({ columnType: "jsonb" })
app_metadata: Record<string, unknown> = {}
@Property({ columnType: "jsonb", nullable: true })
app_metadata: Record<string, unknown> | null
@Property({ columnType: "jsonb", nullable: true })
provider_metadata: Record<string, unknown> | null = null

View File

@@ -1,5 +1,5 @@
import { AuthModuleService } from "@services"
import { ModuleExports } from "@medusajs/types"
import { AuthModuleService } from "@services"
import loadProviders from "./loaders/providers"
const service = AuthModuleService

View File

@@ -1,114 +0,0 @@
import { AuthenticationInput, AuthenticationResponse } from "@medusajs/types"
import {
AbstractAuthModuleProvider,
MedusaError,
isString,
} from "@medusajs/utils"
import { AuthIdentityService } from "@services"
import Scrypt from "scrypt-kdf"
class EmailPasswordProvider extends AbstractAuthModuleProvider {
public static PROVIDER = "emailpass"
public static DISPLAY_NAME = "Email/Password Authentication"
protected readonly authIdentitySerivce_: AuthIdentityService
constructor({
authIdentityService,
}: {
authIdentityService: AuthIdentityService
}) {
super(arguments[0], {
provider: EmailPasswordProvider.PROVIDER,
displayName: EmailPasswordProvider.DISPLAY_NAME,
})
this.authIdentitySerivce_ = authIdentityService
}
private getHashConfig() {
const scopeConfig = this.scopeConfig_.hashConfig as
| Scrypt.ScryptParams
| undefined
const defaultHashConfig = { logN: 15, r: 8, p: 1 }
// Return custom defined hash config or default hash parameters
return scopeConfig ?? defaultHashConfig
}
async authenticate(
userData: AuthenticationInput
): Promise<AuthenticationResponse> {
const { email, password } = userData.body
if (!password || !isString(password)) {
return {
success: false,
error: "Password should be a string",
}
}
if (!email || !isString(email)) {
return {
success: false,
error: "Email should be a string",
}
}
let authIdentity
try {
authIdentity =
await this.authIdentitySerivce_.retrieveByProviderAndEntityId(
email,
EmailPasswordProvider.PROVIDER
)
} catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) {
const password_hash = await Scrypt.kdf(password, this.getHashConfig())
const [createdAuthIdentity] = await this.authIdentitySerivce_.create([
{
entity_id: email,
provider: EmailPasswordProvider.PROVIDER,
scope: this.scope_,
provider_metadata: {
password: password_hash.toString("base64"),
},
},
])
return {
success: true,
authIdentity: JSON.parse(JSON.stringify(createdAuthIdentity)),
}
}
return { success: false, error: error.message }
}
const password_hash = authIdentity.provider_metadata?.password
if (isString(password_hash)) {
const buf = Buffer.from(password_hash as string, "base64")
const success = await Scrypt.verify(buf, password)
if (success) {
delete authIdentity.provider_metadata!.password
return {
success,
authIdentity: JSON.parse(JSON.stringify(authIdentity)),
}
}
}
return {
success: false,
error: "Invalid email or password",
}
}
}
export default EmailPasswordProvider

View File

@@ -1,13 +1,12 @@
import { AuthenticationInput, AuthenticationResponse } from "@medusajs/types"
import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils"
import { AuthIdentityService } from "@services"
import jwt, { JwtPayload } from "jsonwebtoken"
import { AuthorizationCode } from "simple-oauth2"
import url from "url"
type InjectedDependencies = {
authIdentityService: AuthIdentityService
authIdentityService: any
}
type ProviderConfig = {
@@ -18,15 +17,12 @@ type ProviderConfig = {
}
class GoogleProvider extends AbstractAuthModuleProvider {
public static PROVIDER = "google"
public static DISPLAY_NAME = "Google Authentication"
protected readonly authIdentityService_: any
protected readonly authIdentityService_: AuthIdentityService
constructor({ authIdentityService }: InjectedDependencies) {
constructor({ authIdentityService }: InjectedDependencies, options: any) {
super(arguments[0], {
provider: GoogleProvider.PROVIDER,
displayName: GoogleProvider.DISPLAY_NAME,
provider: "google",
displayName: "Google Authentication",
})
this.authIdentityService_ = authIdentityService
@@ -72,6 +68,9 @@ class GoogleProvider extends AbstractAuthModuleProvider {
}
const code = req.query?.code ?? req.body?.code
if (!code) {
return { success: false, error: "No code provided" }
}
return await this.validateCallbackToken(code, config)
}
@@ -89,16 +88,15 @@ class GoogleProvider extends AbstractAuthModuleProvider {
authIdentity =
await this.authIdentityService_.retrieveByProviderAndEntityId(
entity_id,
GoogleProvider.PROVIDER
this.provider
)
} catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) {
const [createdAuthIdentity] = await this.authIdentityService_.create([
{
entity_id,
provider: GoogleProvider.PROVIDER,
provider: this.provider,
user_metadata: jwtData!.payload,
scope: this.scope_,
},
])
authIdentity = createdAuthIdentity
@@ -132,7 +130,7 @@ class GoogleProvider extends AbstractAuthModuleProvider {
accessToken.token.id_token
)
const { successRedirectUrl } = this.getConfigFromScope()
const { successRedirectUrl } = this.getConfig()
return {
success,
@@ -144,8 +142,10 @@ class GoogleProvider extends AbstractAuthModuleProvider {
}
}
private getConfigFromScope(): ProviderConfig {
const config: Partial<ProviderConfig> = { ...this.scopeConfig_ }
private getConfig(): ProviderConfig {
// TODO: Fetch this from provider config
// const config: Partial<ProviderConfig> = { ...this.scopeConfig_ }
const config = {} as any
if (!config.clientID) {
throw new Error("Google clientID is required")
@@ -163,7 +163,7 @@ class GoogleProvider extends AbstractAuthModuleProvider {
}
private originalURL(req: AuthenticationInput) {
const host = req.headers.host
const host = req.headers?.host
const protocol = req.protocol
const path = req.url || ""
@@ -173,7 +173,7 @@ class GoogleProvider extends AbstractAuthModuleProvider {
private async getProviderConfig(
req: AuthenticationInput
): Promise<ProviderConfig> {
const config = this.getConfigFromScope()
const config = this.getConfig()
const callbackURL = config.callbackURL

View File

@@ -1,2 +1 @@
export { default as EmailPasswordProvider } from "./email-password"
export { default as GoogleProvider } from "./google"
export { default as GoogleProvider } from "./google"

View File

@@ -1,65 +0,0 @@
import {
AuthTypes,
Context,
DAL,
FindConfig,
RepositoryService,
} from "@medusajs/types"
import {
InjectManager,
MedusaContext,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils"
import { AuthIdentity } from "@models"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
authIdentityRepository: DAL.RepositoryService
}
export default class AuthIdentityService<
TEntity extends AuthIdentity = AuthIdentity
> extends ModulesSdkUtils.internalModuleServiceFactory<InjectedDependencies>(
AuthIdentity
)<TEntity> {
protected readonly authIdentityRepository_: RepositoryService<TEntity>
protected baseRepository_: DAL.RepositoryService
constructor(container: InjectedDependencies) {
// @ts-ignore
super(...arguments)
this.authIdentityRepository_ = container.authIdentityRepository
this.baseRepository_ = container.baseRepository
}
@InjectManager("authIdentityRepository_")
async retrieveByProviderAndEntityId<
TEntityMethod = AuthTypes.AuthIdentityDTO
>(
entityId: string,
provider: string,
config: FindConfig<TEntityMethod> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<AuthTypes.AuthIdentityDTO> {
const queryConfig = ModulesSdkUtils.buildQuery<TEntity>(
{ entity_id: entityId, provider },
{ ...config, take: 1 }
)
const [result] = await this.authIdentityRepository_.find(
queryConfig,
sharedContext
)
if (!result) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`AuthIdentity with entity_id: "${entityId}" and provider: "${provider}" not found`
)
}
return await this.baseRepository_.serialize<AuthTypes.AuthIdentityDTO>(
result
)
}
}

View File

@@ -1,6 +1,7 @@
import {
AuthenticationInput,
AuthenticationResponse,
AuthIdentityProviderService,
AuthTypes,
Context,
DAL,
@@ -14,16 +15,17 @@ import { AuthIdentity } from "@models"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import {
AbstractAuthModuleProvider,
InjectManager,
MedusaContext,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils"
import AuthProviderService from "./auth-provider"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
authIdentityService: ModulesSdkTypes.InternalModuleService<any>
authProviderService: AuthProviderService
}
const generateMethodForModels = [AuthIdentity]
@@ -42,9 +44,14 @@ export default class AuthModuleService<
{
protected baseRepository_: DAL.RepositoryService
protected authIdentityService_: ModulesSdkTypes.InternalModuleService<TAuthIdentity>
protected readonly authProviderService_: AuthProviderService
constructor(
{ authIdentityService, baseRepository }: InjectedDependencies,
{
authIdentityService,
authProviderService,
baseRepository,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
@@ -52,6 +59,7 @@ export default class AuthModuleService<
this.baseRepository_ = baseRepository
this.authIdentityService_ = authIdentityService
this.authProviderService_ = authProviderService
}
__joinerConfig(): ModuleJoinerConfig {
@@ -116,34 +124,16 @@ export default class AuthModuleService<
return Array.isArray(data) ? serializedUsers : serializedUsers[0]
}
protected getRegisteredAuthenticationProvider(
provider: string,
{ authScope }: AuthenticationInput
): AbstractAuthModuleProvider {
let containerProvider: AbstractAuthModuleProvider
try {
containerProvider = this.__container__[`auth_provider_${provider}`]
} catch (error) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`AuthenticationProvider: ${provider} wasn't registered in the module. Have you configured your options correctly?`
)
}
return containerProvider.withScope(authScope)
}
async authenticate(
provider: string,
authenticationData: AuthenticationInput
): Promise<AuthenticationResponse> {
try {
const registeredProvider = this.getRegisteredAuthenticationProvider(
return await this.authProviderService_.authenticate(
provider,
authenticationData
authenticationData,
this.getAuthIdentityProviderService()
)
return await registeredProvider.authenticate(authenticationData)
} catch (error) {
return { success: false, error: error.message }
}
@@ -154,14 +144,49 @@ export default class AuthModuleService<
authenticationData: AuthenticationInput
): Promise<AuthenticationResponse> {
try {
const registeredProvider = this.getRegisteredAuthenticationProvider(
return await this.authProviderService_.validateCallback(
provider,
authenticationData
authenticationData,
this.getAuthIdentityProviderService()
)
return await registeredProvider.validateCallback(authenticationData)
} catch (error) {
return { success: false, error: error.message }
}
}
getAuthIdentityProviderService(): AuthIdentityProviderService {
return {
retrieve: async ({ entity_id, provider }) => {
const authIdentities = await this.authIdentityService_.list({
entity_id,
provider,
})
if (!authIdentities.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`AuthIdentity with entity_id "${entity_id}" not found`
)
}
if (authIdentities.length > 1) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Multiple authIdentities found for entity_id "${entity_id}"`
)
}
return await this.baseRepository_.serialize<AuthTypes.AuthIdentityDTO>(
authIdentities[0]
)
},
create: async (data: AuthTypes.CreateAuthIdentityDTO) => {
const createdAuthIdentity = await this.authIdentityService_.create(data)
return await this.baseRepository_.serialize<AuthTypes.AuthIdentityDTO>(
createdAuthIdentity
)
},
}
}
}

View File

@@ -0,0 +1,56 @@
import {
AuthTypes,
AuthenticationInput,
AuthIdentityProviderService,
AuthenticationResponse,
} from "@medusajs/types"
import { MedusaError } from "medusa-core-utils"
import { AuthProviderRegistrationPrefix } from "@types"
type InjectedDependencies = {
[
key: `${typeof AuthProviderRegistrationPrefix}${string}`
]: AuthTypes.IAuthProvider
}
export default class AuthProviderService {
protected dependencies: InjectedDependencies
constructor(container: InjectedDependencies) {
this.dependencies = container
}
protected retrieveProviderRegistration(
providerId: string
): AuthTypes.IAuthProvider {
try {
return this.dependencies[`${AuthProviderRegistrationPrefix}${providerId}`]
} catch (err) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Could not find a auth provider with id: ${providerId}`
)
}
}
async authenticate(
provider: string,
auth: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
const providerHandler = this.retrieveProviderRegistration(provider)
return await providerHandler.authenticate(auth, authIdentityProviderService)
}
async validateCallback(
provider: string,
auth: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
const providerHandler = this.retrieveProviderRegistration(provider)
return await providerHandler.validateCallback(
auth,
authIdentityProviderService
)
}
}

View File

@@ -1,2 +1,2 @@
export { default as AuthModuleService } from "./auth-module"
export { default as AuthIdentityService } from "./auth-identity"
export { default as AuthProviderService } from "./auth-provider"

View File

@@ -1,5 +1,29 @@
import { ModuleProviderExports } from "@medusajs/types"
import { ModuleServiceInitializeOptions } from "@medusajs/types"
import { Logger } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
logger?: Logger
}
export const AuthIdentifiersRegistrationName = "auth_providers_identifier"
export const AuthProviderRegistrationPrefix = "au_"
export type AuthModuleOptions = Partial<ModuleServiceInitializeOptions> & {
/**
* Providers to be registered
*/
providers?: {
/**
* The module provider to be registered
*/
resolve: string | ModuleProviderExports
options: {
/**
* key value pair of the provider name and the configuration to be passed to the provider constructor
*/
config: Record<string, unknown>
}
}[]
}