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:
0
packages/modules/providers/auth-emailpass/.gitignore
vendored
Normal file
0
packages/modules/providers/auth-emailpass/.gitignore
vendored
Normal file
0
packages/modules/providers/auth-emailpass/README.md
Normal file
0
packages/modules/providers/auth-emailpass/README.md
Normal 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: {},
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
7
packages/modules/providers/auth-emailpass/jest.config.js
Normal file
7
packages/modules/providers/auth-emailpass/jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
transform: {
|
||||
"^.+\\.[jt]s?$": "@swc/jest",
|
||||
},
|
||||
testEnvironment: `node`,
|
||||
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
|
||||
}
|
||||
40
packages/modules/providers/auth-emailpass/package.json
Normal file
40
packages/modules/providers/auth-emailpass/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
10
packages/modules/providers/auth-emailpass/src/index.ts
Normal file
10
packages/modules/providers/auth-emailpass/src/index.ts
Normal 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
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
32
packages/modules/providers/auth-emailpass/tsconfig.json
Normal file
32
packages/modules/providers/auth-emailpass/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user