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

View File

@@ -0,0 +1,138 @@
import { MedusaError } from "@medusajs/utils"
import Scrypt from "scrypt-kdf"
import { EmailPassAuthService } from "../../src/services/emailpass"
jest.setTimeout(100000)
describe("Email password auth provider", () => {
let emailpassService: EmailPassAuthService
beforeAll(() => {
emailpassService = new EmailPassAuthService(
{
logger: console as any,
},
{}
)
})
afterEach(() => {
jest.restoreAllMocks()
})
it("return error if email is not passed", async () => {
const resp = await emailpassService.authenticate(
{ body: { password: "otherpass" } },
{}
)
expect(resp).toEqual({
error: "Email should be a string",
success: false,
})
})
it("return error if password is not passed", async () => {
const resp = await emailpassService.authenticate(
{ body: { email: "test@admin.com" } },
{}
)
expect(resp).toEqual({
error: "Password should be a string",
success: false,
})
})
it("return error if the passwords don't match", async () => {
const config = { logN: 15, r: 8, p: 1 }
const passwordHash = await Scrypt.kdf("somepass", config)
const authServiceSpies = {
retrieve: jest.fn().mockImplementation(() => {
return {
entity_id: "test@admin.com",
provider: "emailpass",
provider_metadata: {
password: passwordHash.toString("base64"),
},
}
}),
}
const resp = await emailpassService.authenticate(
{ body: { email: "test@admin.com", password: "otherpass" } },
authServiceSpies
)
expect(authServiceSpies.retrieve).toHaveBeenCalled()
expect(resp).toEqual({
error: "Invalid email or password",
success: false,
})
})
it("return an existing entity if the passwords match", async () => {
const config = { logN: 15, r: 8, p: 1 }
const passwordHash = await Scrypt.kdf("somepass", config)
const authServiceSpies = {
retrieve: jest.fn().mockImplementation(() => {
return {
entity_id: "test@admin.com",
provider: "emailpass",
provider_metadata: {
password: passwordHash.toString("base64"),
},
}
}),
}
const resp = await emailpassService.authenticate(
{ body: { email: "test@admin.com", password: "somepass" } },
authServiceSpies
)
expect(authServiceSpies.retrieve).toHaveBeenCalled()
expect(resp).toEqual(
expect.objectContaining({
success: true,
authIdentity: expect.objectContaining({
entity_id: "test@admin.com",
provider_metadata: {},
}),
})
)
})
it("creates a new auth identity if it doesn't exist", async () => {
const authServiceSpies = {
retrieve: jest.fn().mockImplementation(() => {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Not found")
}),
create: jest.fn().mockImplementation(() => {
return {
entity_id: "test@admin.com",
provider: "emailpass",
provider_metadata: {
password: "somehash",
},
}
}),
}
const resp = await emailpassService.authenticate(
{ body: { email: "test@admin.com", password: "test" } },
authServiceSpies
)
expect(authServiceSpies.retrieve).toHaveBeenCalled()
expect(authServiceSpies.create).toHaveBeenCalled()
expect(resp.authIdentity).toEqual(
expect.objectContaining({
entity_id: "test@admin.com",
provider_metadata: {},
})
)
})
})

View File

@@ -0,0 +1,7 @@
module.exports = {
transform: {
"^.+\\.[jt]s?$": "@swc/jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
}

View File

@@ -0,0 +1,40 @@
{
"name": "@medusajs/auth-emailpass",
"version": "0.0.1",
"description": "Email and password credential authentication provider for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/modules/providers/auth-emailpass"
},
"files": [
"dist"
],
"engines": {
"node": ">=16"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"prepublishOnly": "cross-env NODE_ENV=production tsc --build",
"test": "jest --passWithNoTests src",
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts",
"build": "rimraf dist && tsc -p ./tsconfig.json",
"watch": "tsc --watch"
},
"devDependencies": {
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"rimraf": "^5.0.1",
"typescript": "^4.9.5"
},
"dependencies": {
"@medusajs/utils": "^1.11.7",
"scrypt-kdf": "^2.0.1"
},
"keywords": [
"medusa-provider",
"medusa-provider-auth-userpass"
]
}

View File

@@ -0,0 +1,10 @@
import { ModuleProviderExports } from "@medusajs/types"
import { EmailPassAuthService } from "./services/emailpass"
const services = [EmailPassAuthService]
const providerExport: ModuleProviderExports = {
services,
}
export default providerExport

View File

@@ -0,0 +1,110 @@
import {
Logger,
EmailPassAuthProviderOptions,
AuthenticationResponse,
AuthenticationInput,
AuthIdentityProviderService,
} from "@medusajs/types"
import {
AbstractAuthModuleProvider,
MedusaError,
isString,
} from "@medusajs/utils"
import Scrypt from "scrypt-kdf"
type InjectedDependencies = {
logger: Logger
}
interface LocalServiceConfig extends EmailPassAuthProviderOptions {}
export class EmailPassAuthService extends AbstractAuthModuleProvider {
protected config_: LocalServiceConfig
protected logger_: Logger
constructor(
{ logger }: InjectedDependencies,
options: EmailPassAuthProviderOptions
) {
super(
{},
{ provider: "emailpass", displayName: "Email/Password Authentication" }
)
this.config_ = options
this.logger_ = logger
}
async authenticate(
userData: AuthenticationInput,
authIdentityService: AuthIdentityProviderService
): 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 authIdentityService.retrieve({
entity_id: email,
provider: this.provider,
})
} catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) {
const config = this.config_.hashConfig ?? { logN: 15, r: 8, p: 1 }
const passwordHash = await Scrypt.kdf(password, config)
const createdAuthIdentity = await authIdentityService.create({
entity_id: email,
provider: this.provider,
provider_metadata: {
password: passwordHash.toString("base64"),
},
})
const copy = JSON.parse(JSON.stringify(createdAuthIdentity))
delete copy.provider_metadata?.password
return {
success: true,
authIdentity: copy,
}
}
return { success: false, error: error.message }
}
const passwordHash = authIdentity.provider_metadata?.password
if (isString(passwordHash)) {
const buf = Buffer.from(passwordHash as string, "base64")
const success = await Scrypt.verify(buf, password)
if (success) {
const copy = JSON.parse(JSON.stringify(authIdentity))
delete copy.provider_metadata!.password
return {
success,
authIdentity: copy,
}
}
}
return {
success: false,
error: "Invalid email or password",
}
}
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"lib": ["es2021"],
"target": "es2021",
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true, // to use ES5 specific tooling
"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
},
"include": ["src"],
"exclude": [
"dist",
"build",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules",
".eslintrc.js"
]
}