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

@@ -33,7 +33,7 @@ medusaIntegrationTestRunner({
})
},
async () => {
return await api.post("/auth/admin/emailpass", {
return await api.post("/auth/user/emailpass", {
email: "admin@medusa.js",
password: "secret_password",
})
@@ -63,7 +63,7 @@ medusaIntegrationTestRunner({
it("should test the entire authentication lifecycle", async () => {
// sign in
const response = await api.post("/auth/admin/emailpass", {
const response = await api.post("/auth/user/emailpass", {
email: "admin@medusa.js",
password: "secret_password",
})

View File

@@ -175,7 +175,7 @@ medusaIntegrationTestRunner({
token = await breaking(
() => null,
async () => {
const emailPassResponse = await api.post("/auth/admin/emailpass", {
const emailPassResponse = await api.post("/auth/user/emailpass", {
email: "test@test123.com",
password: "test123",
})
@@ -230,7 +230,7 @@ medusaIntegrationTestRunner({
// V2 only test
it.skip("should throw, if session/bearer auth is present for existing user", async () => {
const emailPassResponse = await api.post("/auth/admin/emailpass", {
const emailPassResponse = await api.post("/auth/user/emailpass", {
email: "test@test123.com",
password: "test123",
})

View File

@@ -41,22 +41,7 @@ module.exports = {
options: { ttl: cacheTTL },
},
workflows: true,
[Modules.AUTH]: {
scope: "internal",
resources: "shared",
resolve: "@medusajs/auth",
options: {
providers: [
{
name: "emailpass",
scopes: {
admin: {},
store: {},
},
},
],
},
},
[Modules.AUTH]: true,
[Modules.USER]: {
scope: "internal",
resources: "shared",

View File

@@ -32,7 +32,6 @@ export const createAdminUser = async (
const authIdentity = await authModule.create({
provider: "emailpass",
entity_id: "admin@medusa.js",
scope: "admin",
provider_metadata: {
password: "somepassword",
},
@@ -55,8 +54,6 @@ export const createAdminUser = async (
actor_id: user.id,
actor_type: "user",
auth_identity_id: authIdentity.id,
scope: "admin",
app_metadata: {},
},
"test"
)

View File

@@ -43,14 +43,13 @@ medusaIntegrationTestRunner({
await authService.create({
provider: "emailpass",
entity_id: email,
scope: "admin",
provider_metadata: {
password: passwordHash,
},
})
const response = await api
.post(`/auth/admin/emailpass`, {
.post(`/auth/user/emailpass`, {
email: email,
password: password,
})
@@ -75,14 +74,13 @@ medusaIntegrationTestRunner({
await authService.create({
provider: "emailpass",
entity_id: email,
scope: "admin",
provider_metadata: {
password: passwordHash,
},
})
const error = await api
.post(`/auth/admin/emailpass`, {
.post(`/auth/user/emailpass`, {
email: email,
password: "incorrect-password",
})
@@ -101,7 +99,7 @@ medusaIntegrationTestRunner({
).toString("base64")
const error = await api
.post(`/auth/admin/emailpass`, {
.post(`/auth/user/emailpass`, {
email: "should-not-exist",
password: "should-not-exist",
})

View File

@@ -38,7 +38,6 @@ medusaIntegrationTestRunner({
const authIdentity = await authService.create({
entity_id: "store_user",
provider: "emailpass",
scope: "store",
})
const token = jwt.sign(authIdentity, http.jwtSecret)

View File

@@ -27,7 +27,7 @@ medusaIntegrationTestRunner({
})
it("should fail to accept an invite with an invalid invite token", async () => {
const authResponse = await api.post(`/auth/admin/emailpass`, {
const authResponse = await api.post(`/auth/user/emailpass`, {
email: "potential_member@test.com",
password: "supersecret",
})
@@ -58,7 +58,7 @@ medusaIntegrationTestRunner({
email: "potential_member@test.com",
})
const authResponse = await api.post(`/auth/admin/emailpass`, {
const authResponse = await api.post(`/auth/user/emailpass`, {
email: "potential_member@test.com",
password: "supersecret",
})
@@ -92,7 +92,7 @@ medusaIntegrationTestRunner({
email: "potential_member@test.com",
})
const authResponse = await api.post(`/auth/admin/emailpass`, {
const authResponse = await api.post(`/auth/user/emailpass`, {
email: "some-email@test.com",
password: "supersecret",
})

View File

@@ -25,7 +25,6 @@ export const createAuthenticatedCustomer = async (
const authIdentity = await authService.create({
entity_id: "store_user",
provider: "emailpass",
scope: "store",
})
// Ideally we simulate a signup process than manually linking here.
@@ -45,8 +44,6 @@ export const createAuthenticatedCustomer = async (
actor_id: customer.id,
actor_type: "customer",
auth_identity_id: authIdentity.id,
scope: "store",
app_metadata: {},
},
http.jwtSecret
)

View File

@@ -47,22 +47,7 @@ module.exports = {
medusa_v2: enableMedusaV2,
},
modules: {
[Modules.AUTH]: {
scope: "internal",
resources: "shared",
resolve: "@medusajs/auth",
options: {
providers: [
{
name: "emailpass",
scopes: {
admin: {},
store: {},
},
},
],
},
},
[Modules.AUTH]: true,
[Modules.USER]: {
scope: "internal",
resources: "shared",

View File

@@ -5,7 +5,7 @@ status_code=$(curl \
-H "Content-Type: application/json"\
-d '{"email":"'$1'", "password":"'$2'"}'\
--write-out %{http_code}\
http://localhost:9000/auth/admin/emailpass)
http://localhost:9000/auth/user/emailpass)
if [[ "$status_code" -ne 200 ]] ; then
echo "Site status changed to $status_code"

View File

@@ -7,7 +7,7 @@ export const useEmailPassLogin = (
options?: UseMutationOptions<void, Error, EmailPassReq>
) => {
return useMutation({
mutationFn: (payload) => sdk.auth.login("admin", "emailpass", payload),
mutationFn: (payload) => sdk.auth.login("user", "emailpass", payload),
onSuccess: async (data, variables, context) => {
options?.onSuccess?.(data, variables, context)
},
@@ -26,7 +26,7 @@ export const useCreateAuthUser = (
options?: UseMutationOptions<{ token: string }, Error, EmailPassReq>
) => {
return useMutation({
mutationFn: (payload) => sdk.auth.create("admin", "emailpass", payload),
mutationFn: (payload) => sdk.auth.create("user", "emailpass", payload),
onSuccess: async (data, variables, context) => {
options?.onSuccess?.(data, variables, context)
},

View File

@@ -10,13 +10,13 @@ export class Auth {
this.config = config
}
public login = async (
scope: "admin" | "store",
login = async (
actor: "customer" | "user",
method: "emailpass",
payload: { email: string; password: string }
) => {
const { token } = await this.client.fetch<{ token: string }>(
`/auth/${scope}/${method}`,
`/auth/${actor}/${method}`,
{
method: "POST",
body: payload,
@@ -45,11 +45,11 @@ export class Auth {
}
create = async (
scope: "admin" | "store",
actor: "customer" | "user",
method: "emailpass",
payload: { email: string; password: string }
): Promise<{ token: string }> => {
return await this.client.fetch(`/auth/${scope}/${method}`, {
return await this.client.fetch(`/auth/${actor}/${method}`, {
method: "POST",
body: payload,
})

View File

@@ -22,12 +22,6 @@ export type AuthIdentityDTO = {
*/
entity_id: string
/**
* The scope of the auth identity. For example,
* `admin` or `store`.
*/
scope: string
/**
* Holds custom data related to the provider in key-value pairs.
*/
@@ -37,11 +31,6 @@ export type AuthIdentityDTO = {
* Holds custom data related to the user in key-value pairs.
*/
user_metadata: Record<string, unknown>
/**
* Holds custom data related to the third-party app in key-value pairs.
*/
app_metadata: Record<string, unknown>
}
/**
@@ -67,12 +56,6 @@ export type CreateAuthIdentityDTO = {
*/
entity_id: string
/**
* The scope of the auth identity. For example,
* `admin` or `store`.
*/
scope: string
/**
* Holds custom data related to the provider in key-value pairs.
*/
@@ -82,11 +65,6 @@ export type CreateAuthIdentityDTO = {
* Holds custom data related to the user in key-value pairs.
*/
user_metadata?: Record<string, unknown>
/**
* Holds custom data related to the third-party app in key-value pairs.
*/
app_metadata?: Record<string, unknown>
}
/**
@@ -109,11 +87,6 @@ export type UpdateAuthIdentityDTO = {
* Holds custom data related to the user in key-value pairs.
*/
user_metadata?: Record<string, unknown>
/**
* Holds custom data related to the third-party app in key-value pairs.
*/
app_metadata?: Record<string, unknown>
}
/**

View File

@@ -42,31 +42,6 @@ export type AuthenticationResponse = {
successRedirectUrl?: string
}
/**
* @interface
*
* The configurations of the `providers` option
* passed to the Auth Module.
*/
export type AuthModuleProviderConfig = {
/**
* The provider's name.
*/
name: string
/**
* The scopes configuration of that provider.
*/
scopes: Record<string, AuthProviderScope>
}
/**
* @interface
*
* The scope configurations of an auth provider.
*/
export type AuthProviderScope = Record<string, unknown>
/**
* @interface
*
@@ -77,30 +52,25 @@ export type AuthenticationInput = {
/**
* URL of the incoming authentication request.
*/
url: string
url?: string
/**
* Headers of incoming authentication request.
*/
headers: Record<string, string>
headers?: Record<string, string>
/**
* Query params of the incoming authentication request.
*/
query: Record<string, string>
query?: Record<string, string>
/**
* Body of the incoming authentication request.
*/
body: Record<string, string>
/**
* Scope for the authentication request.
*/
authScope: string
body?: Record<string, string>
/**
* Protocol of the incoming authentication request (For example, `https`).
*/
protocol: string
protocol?: string
}

View File

@@ -1,2 +1,4 @@
export * from "./service"
export * from "./common"
export * from "./provider"
export * from "./providers"

View File

@@ -0,0 +1,32 @@
import {
AuthIdentityDTO,
AuthenticationInput,
AuthenticationResponse,
CreateAuthIdentityDTO,
} from "./common"
export interface AuthIdentityProviderService {
// The provider is injected by the auth identity module
retrieve: (selector: {
entity_id: string
provider: string
}) => Promise<AuthIdentityDTO>
create: (data: CreateAuthIdentityDTO) => Promise<AuthIdentityDTO>
}
/**
* ## Overview
*
* Authentication provider interface for the auth module.
*
*/
export interface IAuthProvider {
authenticate(
data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse>
validateCallback(
data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse>
}

View File

@@ -0,0 +1,7 @@
export interface EmailPassAuthProviderOptions {
hashConfig?: {
logN: number
r: number
p: number
}
}

View File

@@ -0,0 +1 @@
export * from "./emailpass"

View File

@@ -43,7 +43,6 @@ export interface IAuthModuleService extends IModuleService {
* headers: req.headers,
* query: req.query,
* body: req.body,
* authScope: "admin",
* protocol: req.protocol,
* } as AuthenticationInput)
* ```
@@ -81,7 +80,6 @@ export interface IAuthModuleService extends IModuleService {
* headers: req.headers,
* query: req.query,
* body: req.body,
* authScope: "admin",
* protocol: req.protocol,
* } as AuthenticationInput)
* ```
@@ -200,12 +198,10 @@ export interface IAuthModuleService extends IModuleService {
* {
* provider: "emailpass",
* entity_id: "user@example.com",
* scope: "admin",
* },
* {
* provider: "google",
* entity_id: "user@gmail.com",
* scope: "email profile",
* },
* ])
*/
@@ -225,7 +221,6 @@ export interface IAuthModuleService extends IModuleService {
* const authIdentity = await authModuleService.create({
* provider: "emailpass",
* entity_id: "user@example.com",
* scope: "admin",
* })
*/
create(
@@ -244,9 +239,6 @@ export interface IAuthModuleService extends IModuleService {
* const authIdentities = await authModuleService.update([
* {
* id: "authusr_123",
* app_metadata: {
* test: true,
* },
* },
* ])
*/
@@ -265,9 +257,6 @@ export interface IAuthModuleService extends IModuleService {
* @example
* const authIdentity = await authModuleService.update({
* id: "authusr_123",
* app_metadata: {
* test: true,
* },
* })
*/
update(

View File

@@ -705,6 +705,35 @@ export type ProjectConfigOptions = {
* ```
*/
adminCors: string
/**
* Optionally you can specify the supported authentication providers per actor type (such as user, customer, or any custom actors).
* For example, you only want to allow SSO logins for `users` to the admin, while you want to allow email/password logins for `customers` to the storefront.
*
* `authMethodsPerActor` is a a map where the actor type (eg. 'user') is the key, and an array of supported auth providers as the value.
*
*
* @example
* Some example values of common use cases:
*
* Then, set the configuration in `medusa-config.js`:
*
* ```js title="medusa-config.js"
* module.exports = {
* projectConfig: {
* http: {
* authMethodsPerActor: {
* user: ['sso'],
* customer: ["emailpass", "google"]
* },
* },
* // ...
* },
* // ...
* }
* ```
*/
authMethodsPerActor: Record<string, string[]>
}
}

View File

@@ -1,17 +1,15 @@
import { AuthProviderScope, AuthenticationResponse } from "@medusajs/types"
import {
AuthIdentityProviderService,
AuthenticationInput,
AuthenticationResponse,
IAuthProvider,
} from "@medusajs/types"
import { MedusaError } from "../common"
export abstract class AbstractAuthModuleProvider {
public static PROVIDER: string
public static DISPLAY_NAME: string
export abstract class AbstractAuthModuleProvider implements IAuthProvider {
private static PROVIDER: string
private static DISPLAY_NAME: string
protected readonly container_: any
protected scopeConfig_: AuthProviderScope
protected scope_: string
private readonly scopes_: Record<string, AuthProviderScope>
public get provider() {
return (this.constructor as typeof AbstractAuthModuleProvider).PROVIDER
}
@@ -20,43 +18,22 @@ export abstract class AbstractAuthModuleProvider {
return (this.constructor as typeof AbstractAuthModuleProvider).DISPLAY_NAME
}
protected constructor(
{ scopes },
config: { provider: string; displayName: string }
) {
protected constructor({}, config: { provider: string; displayName: string }) {
this.container_ = arguments[0]
this.scopes_ = scopes
;(this.constructor as typeof AbstractAuthModuleProvider).PROVIDER ??=
config.provider
;(this.constructor as typeof AbstractAuthModuleProvider).DISPLAY_NAME ??=
config.displayName
}
private validateScope(scope) {
if (!this.scopes_[scope]) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
`Scope "${scope}" is not valid for provider ${this.provider}`
)
}
}
public withScope(scope: string) {
this.validateScope(scope)
const cloned = new (this.constructor as any)(this.container_)
cloned.scope_ = scope
cloned.scopeConfig_ = this.scopes_[scope]
return cloned
}
abstract authenticate(
data: Record<string, unknown>
data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse>
public validateCallback(
data: Record<string, unknown>
validateCallback(
data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
throw new Error(
`Callback authentication not implemented for provider ${this.provider}`

View File

@@ -16,7 +16,7 @@ import { createLinkBody } from "../../utils/validators"
export const adminApiKeyRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/admin/api-keys*",
middlewares: [authenticate("admin", ["bearer", "session"])],
middlewares: [authenticate("user", ["bearer", "session"])],
},
{
method: ["GET"],

View File

@@ -14,7 +14,7 @@ import {
export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/admin/campaigns*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -15,7 +15,7 @@ export const adminCollectionRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/collections*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{

View File

@@ -8,7 +8,7 @@ export const adminCurrencyRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/currencies*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -15,7 +15,7 @@ export const adminCustomerGroupRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/customer-groups*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -19,7 +19,7 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/customers*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -13,7 +13,7 @@ export const adminDraftOrderRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/draft-orders*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -8,7 +8,7 @@ export const adminFulfillmentProvidersRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/fulfillment-providers*",
middlewares: [authenticate("admin", ["session", "bearer", "api-key"])],
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
{
method: ["GET"],

View File

@@ -14,7 +14,7 @@ export const adminFulfillmentSetsRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/fulfillment-sets*",
middlewares: [authenticate("admin", ["session", "bearer", "api-key"])],
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
{
method: ["POST"],

View File

@@ -14,7 +14,7 @@ export const adminFulfillmentsRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/fulfillments*",
middlewares: [authenticate("admin", ["session", "bearer", "api-key"])],
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
{
method: ["POST"],

View File

@@ -20,7 +20,7 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/inventory-items*",
middlewares: [authenticate("admin", ["session", "bearer", "api-key"])],
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
{
method: ["GET"],

View File

@@ -18,7 +18,7 @@ export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/invites",
middlewares: [
authenticate("admin", ["session", "bearer", "api-key"]),
authenticate("user", ["session", "bearer", "api-key"]),
validateAndTransformQuery(
AdminGetInvitesParams,
QueryConfig.listTransformQueryConfig
@@ -29,7 +29,7 @@ export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/invites",
middlewares: [
authenticate("admin", ["session", "bearer", "api-key"]),
authenticate("user", ["session", "bearer", "api-key"]),
validateAndTransformBody(AdminCreateInvite),
validateAndTransformQuery(
AdminGetInviteParams,
@@ -41,7 +41,7 @@ export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
method: "POST",
matcher: "/admin/invites/accept",
middlewares: [
authenticate("admin", ["session", "bearer"], {
authenticate("user", ["session", "bearer"], {
allowUnregistered: true,
}),
validateAndTransformBody(AdminInviteAccept),
@@ -55,7 +55,7 @@ export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/invites/:id",
middlewares: [
authenticate("admin", ["session", "bearer", "api-key"]),
authenticate("user", ["session", "bearer", "api-key"]),
validateAndTransformQuery(
AdminGetInviteParams,
QueryConfig.retrieveTransformQueryConfig
@@ -65,13 +65,13 @@ export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["DELETE"],
matcher: "/admin/invites/:id",
middlewares: [authenticate("admin", ["session", "bearer", "api-key"])],
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
{
method: "POST",
matcher: "/admin/invites/:id/resend",
middlewares: [
authenticate("admin", ["session", "bearer", "api-key"]),
authenticate("user", ["session", "bearer", "api-key"]),
validateAndTransformQuery(
AdminGetInviteParams,
QueryConfig.retrieveTransformQueryConfig

View File

@@ -14,7 +14,7 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/orders*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -16,7 +16,7 @@ export const adminPaymentRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/payments",
middlewares: [authenticate("admin", ["session", "bearer", "api-key"])],
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
{
method: ["GET"],

View File

@@ -18,7 +18,7 @@ export const adminPriceListsRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/price-lists*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -13,7 +13,7 @@ import {
export const adminPricingRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/admin/pricing*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -15,7 +15,7 @@ export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/product-categories*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -14,7 +14,7 @@ export const adminProductTypeRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/product-types/*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{

View File

@@ -28,7 +28,7 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/products*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -19,7 +19,7 @@ import {
export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/admin/promotions*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -14,7 +14,7 @@ export const adminRegionRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/regions*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -15,7 +15,7 @@ export const adminReservationRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/reservations*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -13,7 +13,7 @@ export const adminReturnReasonRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/return-reasons*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -13,7 +13,7 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/returns*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -16,7 +16,7 @@ export const adminSalesChannelRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/sales-channels*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -21,7 +21,7 @@ import { createBatchBody } from "../../utils/validators"
export const adminShippingOptionRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/admin/shipping-options*",
middlewares: [authenticate("admin", ["bearer", "session"])],
middlewares: [authenticate("user", ["bearer", "session"])],
},
{
method: ["GET"],

View File

@@ -15,7 +15,7 @@ import {
export const adminShippingProfilesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/admin/shipping-profiles*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["POST"],

View File

@@ -17,7 +17,7 @@ export const adminStockLocationRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/stock-locations*",
middlewares: [authenticate("admin", ["session", "bearer", "api-key"])],
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
{
method: ["POST"],

View File

@@ -13,7 +13,7 @@ export const adminStoreRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/stores*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -17,7 +17,7 @@ export const adminTaxRateRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/tax-rates*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: "POST",

View File

@@ -15,7 +15,7 @@ export const adminTaxRegionRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/tax-regions*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: "POST",

View File

@@ -14,7 +14,7 @@ export const adminUploadRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/uploads*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
// TODO: There is a `/protected` route in v1 that might need a bit more thought when implementing
{

View File

@@ -17,7 +17,7 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/users",
middlewares: [
authenticate("admin", ["bearer", "session"]),
authenticate("user", ["bearer", "session"]),
validateAndTransformQuery(
AdminGetUsersParams,
QueryConfig.listTransformQueryConfig
@@ -28,7 +28,7 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/users",
middlewares: [
authenticate("admin", ["bearer", "session"], { allowUnregistered: true }),
authenticate("user", ["bearer", "session"], { allowUnregistered: true }),
validateAndTransformBody(AdminCreateUser),
validateAndTransformQuery(
AdminGetUserParams,
@@ -40,7 +40,7 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/users/:id",
middlewares: [
authenticate("admin", ["bearer", "session"]),
authenticate("user", ["bearer", "session"]),
validateAndTransformQuery(
AdminGetUserParams,
QueryConfig.retrieveTransformQueryConfig
@@ -51,7 +51,7 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/users/me",
middlewares: [
authenticate("admin", ["bearer", "session"]),
authenticate("user", ["bearer", "session"]),
validateAndTransformQuery(
AdminGetUserParams,
QueryConfig.retrieveTransformQueryConfig
@@ -62,7 +62,7 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/users/:id",
middlewares: [
authenticate("admin", ["bearer", "session"]),
authenticate("user", ["bearer", "session"]),
validateAndTransformBody(AdminUpdateUser),
validateAndTransformQuery(
AdminGetUserParams,
@@ -73,6 +73,6 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["DELETE"],
matcher: "/admin/users/:id",
middlewares: [authenticate("admin", ["bearer", "session"])],
middlewares: [authenticate("user", ["bearer", "session"])],
},
]

View File

@@ -73,8 +73,6 @@ export const POST = async (
actor_id: result.id,
actor_type: "user",
auth_identity_id: req.auth_context.auth_identity_id,
app_metadata: {},
scope: "admin",
},
{
secret: jwtSecret,

View File

@@ -16,7 +16,7 @@ export const adminWorkflowsExecutionsMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/workflows-executions*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
middlewares: [authenticate("user", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],

View File

@@ -1,5 +1,9 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { AuthenticationInput, IAuthModuleService } from "@medusajs/types"
import {
AuthenticationInput,
IAuthModuleService,
ConfigModule,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
MedusaError,
@@ -9,10 +13,23 @@ import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { generateJwtToken } from "../../../../utils/auth/token"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { scope, auth_provider } = req.params
const actorType = scope === "admin" ? "user" : "customer"
const { actor_type, auth_provider } = req.params
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const config: ConfigModule = req.scope.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
)
const authMethods = (config.projectConfig?.http as any)?.authMethods ?? {}
// Not having the config defined would allow for all auth providers for the particular actor.
if (authMethods[actor_type]) {
if (!authMethods[actor_type].includes(auth_provider)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`The actor type ${actor_type} is not allowed to use the auth provider ${auth_provider}`
)
}
}
const service: IAuthModuleService = req.scope.resolve(
ModuleRegistrationName.AUTH
)
@@ -22,7 +39,6 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
headers: req.headers,
query: req.query,
body: req.body,
authScope: scope,
protocol: req.protocol,
} as AuthenticationInput
@@ -31,11 +47,11 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const queryObject = remoteQueryObjectFromString({
entryPoint: "auth_identity",
fields: [`${actorType}.id`],
fields: [`${actor_type}.id`],
variables: { id: authIdentity.id },
})
const [actorData] = await remoteQuery(queryObject)
const entityId = actorData?.[actorType]?.id
const entityId = actorData?.[actor_type]?.id
if (success) {
const { http } = req.scope.resolve(
@@ -43,14 +59,11 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
).projectConfig
const { jwtSecret, jwtExpiresIn } = http
// TODO: Clean up mapping between scope and actor type
const token = generateJwtToken(
{
actor_id: entityId,
actor_type: actorType,
actor_type,
auth_identity_id: authIdentity.id,
app_metadata: {},
scope,
},
{
secret: jwtSecret,

View File

@@ -1,5 +1,9 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { AuthenticationInput, IAuthModuleService } from "@medusajs/types"
import {
AuthenticationInput,
IAuthModuleService,
ConfigModule,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
MedusaError,
@@ -9,9 +13,22 @@ import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { generateJwtToken } from "../../../utils/auth/token"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { scope, auth_provider } = req.params
const actorType = scope === "admin" ? "user" : "customer"
const { actor_type, auth_provider } = req.params
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const config: ConfigModule = req.scope.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
)
const authMethods = (config.projectConfig?.http as any)?.authMethods ?? {}
// Not having the config defined would allow for all auth providers for the particular actor.
if (authMethods[actor_type]) {
if (!authMethods[actor_type].includes(auth_provider)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`The actor type ${actor_type} is not allowed to use the auth provider ${auth_provider}`
)
}
}
const service: IAuthModuleService = req.scope.resolve(
ModuleRegistrationName.AUTH
@@ -22,7 +39,6 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
headers: req.headers,
query: req.query,
body: req.body,
authScope: scope,
protocol: req.protocol,
} as AuthenticationInput
@@ -43,21 +59,18 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const queryObject = remoteQueryObjectFromString({
entryPoint: "auth_identity",
fields: [`${actorType}.id`],
fields: [`${actor_type}.id`],
variables: { id: authIdentity.id },
})
const [actorData] = await remoteQuery(queryObject)
const entityId = actorData?.[actorType]?.id
const entityId = actorData?.[actor_type]?.id
const { jwtSecret, jwtExpiresIn } = http
// TODO: Clean up mapping between scope and actor type
const token = generateJwtToken(
{
actor_id: entityId,
actor_type: actorType,
actor_type,
auth_identity_id: authIdentity.id,
app_metadata: {},
scope,
},
{
secret: jwtSecret,

View File

@@ -14,12 +14,12 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [
},
{
method: ["POST"],
matcher: "/auth/:scope/:auth_provider/callback",
matcher: "/auth/:actor_type/:auth_provider/callback",
middlewares: [],
},
{
method: ["POST"],
matcher: "/auth/:scope/:auth_provider",
matcher: "/auth/:actor_type/:auth_provider",
middlewares: [],
},
]

View File

@@ -25,7 +25,7 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
method: "ALL",
matcher: "/store/carts*",
middlewares: [
authenticate("store", ["session", "bearer"], {
authenticate("customer", ["session", "bearer"], {
allowUnauthenticated: true,
}),
],

View File

@@ -20,7 +20,9 @@ export const storeCustomerRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/store/customers",
middlewares: [
authenticate("store", ["session", "bearer"], { allowUnregistered: true }),
authenticate("customer", ["session", "bearer"], {
allowUnregistered: true,
}),
validateAndTransformBody(StoreCreateCustomer),
validateAndTransformQuery(
StoreGetCustomerParams,
@@ -31,7 +33,7 @@ export const storeCustomerRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/store/customers/me*",
middlewares: [authenticate("store", ["session", "bearer"])],
middlewares: [authenticate("customer", ["session", "bearer"])],
},
{
method: ["GET"],

View File

@@ -41,8 +41,6 @@ export const POST = async (
actor_id: result.id,
actor_type: "customer",
auth_identity_id: req.auth_context.auth_identity_id,
app_metadata: {},
scope: "store",
},
{
secret: jwtSecret,

View File

@@ -9,7 +9,7 @@ export const storeOrderRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/store/orders",
middlewares: [
authenticate("store", ["session", "bearer"]),
authenticate("customer", ["session", "bearer"]),
validateAndTransformQuery(
StoreGetOrdersParams,
QueryConfig.listTransformQueryConfig
@@ -20,7 +20,7 @@ export const storeOrderRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/store/orders/:id",
middlewares: [
authenticate("store", ["session", "bearer"], {
authenticate("customer", ["session", "bearer"], {
allowUnauthenticated: true,
}),
validateAndTransformQuery(

View File

@@ -14,7 +14,7 @@ export const storePaymentCollectionsMiddlewares: MiddlewareRoute[] = [
method: "ALL",
matcher: "/store/payment-collections*",
middlewares: [
authenticate("store", ["session", "bearer"], {
authenticate("customer", ["session", "bearer"], {
allowUnauthenticated: true,
}),
],

View File

@@ -19,7 +19,7 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
method: "ALL",
matcher: "/store/products*",
middlewares: [
authenticate("store", ["session", "bearer"], {
authenticate("customer", ["session", "bearer"], {
allowUnauthenticated: true,
}),
],

View File

@@ -46,14 +46,18 @@ export default async function ({
} else {
const user = await userService.create({ email })
const { authIdentity } = await authService.authenticate(provider, {
const { authIdentity, error } = await authService.authenticate(provider, {
body: {
email,
password,
},
authScope: "admin",
})
if (error) {
Logger.error(error)
throw new Error(error)
}
await remoteLink.create([
{
[Modules.USER]: {

View File

@@ -61,11 +61,8 @@ export interface MedusaRequest<Body = unknown>
export interface AuthContext {
actor_id: string
// TODO: We possibly want to make this more open-ended so it's easy to extend.
actor_type: "api-key" | "user" | "customer" | "unknown"
actor_type: string
auth_identity_id: string
scope: string
app_metadata: Record<string, any>
}
export interface AuthenticatedMedusaRequest<Body = never>

View File

@@ -8,25 +8,23 @@ import {
MedusaRequest,
MedusaResponse,
} from "../../types/routing"
import { ContainerRegistrationKeys } from "@medusajs/utils"
const SESSION_AUTH = "session"
const BEARER_AUTH = "bearer"
const API_KEY_AUTH = "api-key"
// This is the only hard-coded actor type, as API keys have special handling for now. We could also generalize API keys to carry the actor type with them.
const ADMIN_ACTOR_TYPE = "user"
type AuthType = typeof SESSION_AUTH | typeof BEARER_AUTH | typeof API_KEY_AUTH
const ADMIN_SCOPE = "admin"
const STORE_SCOPE = "store"
const ALL_SCOPE = "*"
type Scope = typeof ADMIN_SCOPE | typeof STORE_SCOPE | typeof ALL_SCOPE
type MedusaSession = {
auth_context: AuthContext
}
export const authenticate = (
authScope: Scope | Scope[],
actorType: string,
authType: AuthType | AuthType[],
options: { allowUnauthenticated?: boolean; allowUnregistered?: boolean } = {}
): RequestHandler => {
@@ -36,21 +34,20 @@ export const authenticate = (
next: NextFunction
): Promise<void> => {
const authTypes = Array.isArray(authType) ? authType : [authType]
const scopes = Array.isArray(authScope) ? authScope : [authScope]
const actorTypes = Array.isArray(actorType) ? actorType : [actorType]
const req_ = req as AuthenticatedMedusaRequest
// We only allow authenticating using a secret API key on the admin
const isExclusivelyAdmin =
scopes.length === 1 && scopes.includes(ADMIN_SCOPE)
if (authTypes.includes(API_KEY_AUTH) && isExclusivelyAdmin) {
const isExclusivelyUser =
actorTypes.length === 1 && actorTypes[0] === ADMIN_ACTOR_TYPE
if (authTypes.includes(API_KEY_AUTH) && isExclusivelyUser) {
const apiKey = await getApiKeyInfo(req)
if (apiKey) {
req_.auth_context = {
actor_id: apiKey.id,
actor_type: "api-key",
auth_identity_id: "",
app_metadata: {},
scope: ADMIN_SCOPE,
}
return next()
@@ -61,27 +58,29 @@ export const authenticate = (
let authContext: AuthContext | null = getAuthContextFromSession(
req.session,
authTypes,
scopes
actorTypes
)
if (!authContext) {
const { http } =
req.scope.resolve<ConfigModule>("configModule").projectConfig
const { http } = req.scope.resolve<ConfigModule>(
ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig
authContext = getAuthContextFromJwtToken(
req.headers.authorization,
http.jwtSecret!,
authTypes,
scopes
actorTypes
)
}
// If the entity is authenticated, and it is a registered user/customer we can continue
if (!!authContext?.actor_id && authContext.actor_type !== "unknown") {
// If the entity is authenticated, and it is a registered actor we can continue
if (authContext?.actor_id) {
req_.auth_context = authContext
return next()
}
// If the entity is authenticated, but there is no user/customer yet, we can continue (eg. in the case of a user invite) if allow unregistered is set
// If the entity is authenticated, but there is no registered actor yet, we can continue (eg. in the case of a user invite) if allow unregistered is set
if (authContext?.auth_identity_id && options.allowUnregistered) {
req_.auth_context = authContext
return next()
@@ -142,7 +141,7 @@ const getApiKeyInfo = async (req: MedusaRequest): Promise<ApiKeyDTO | null> => {
const getAuthContextFromSession = (
session: Partial<MedusaSession> = {},
authTypes: AuthType[],
scopes: Scope[]
actorTypes: string[]
): AuthContext | null => {
if (!authTypes.includes(SESSION_AUTH)) {
return null
@@ -150,8 +149,8 @@ const getAuthContextFromSession = (
if (
session.auth_context &&
(scopes.includes("*") ||
scopes.includes(session.auth_context.scope as Scope))
(actorTypes.includes("*") ||
actorTypes.includes(session.auth_context.actor_type))
) {
return session.auth_context
}
@@ -163,7 +162,7 @@ const getAuthContextFromJwtToken = (
authHeader: string | undefined,
jwtSecret: string,
authTypes: AuthType[],
scopes: Scope[]
actorTypes: string[]
): AuthContext | null => {
if (!authTypes.includes(BEARER_AUTH)) {
return null
@@ -185,7 +184,10 @@ const getAuthContextFromJwtToken = (
// verify token and set authUser
try {
const verified = jwt.verify(token, jwtSecret) as JwtPayload
if (scopes.includes("*") || scopes.includes(verified.scope)) {
if (
actorTypes.includes("*") ||
actorTypes.includes(verified.actor_type)
) {
return verified as AuthContext
}
} catch (err) {

View File

@@ -8,18 +8,15 @@ export async function createAuthIdentities(
id: "test-id",
entity_id: "test-id",
provider: "manual",
scope: "store",
},
{
id: "test-id-1",
entity_id: "test-id-1",
provider: "manual",
scope: "store",
},
{
entity_id: "test-id-2",
provider: "store",
scope: "store",
},
]
): Promise<AuthIdentity[]> {

View File

@@ -0,0 +1,61 @@
import {
AuthIdentityDTO,
AuthIdentityProviderService,
AuthenticationInput,
AuthenticationResponse,
} from "@medusajs/types"
import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils"
export class AuthServiceFixtures extends AbstractAuthModuleProvider {
constructor() {
super(
{},
{ provider: "plaintextpass", displayName: "plaintextpass Fixture" }
)
}
async authenticate(
authenticationData: AuthenticationInput,
service: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
const { email, password } = authenticationData.body ?? {}
let authIdentity: AuthIdentityDTO | undefined
try {
authIdentity = await service.retrieve({
entity_id: email,
provider: this.provider,
})
if (authIdentity.provider_metadata?.password === password) {
return {
success: true,
authIdentity,
}
}
} catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) {
const createdAuthIdentity = await service.create({
entity_id: email,
provider: this.provider,
provider_metadata: {
password,
},
})
return {
success: true,
authIdentity: createdAuthIdentity,
}
}
return { success: false, error: error.message }
}
return {
success: false,
error: "Invalid email or password",
}
}
}
export const services = [AuthServiceFixtures]

View File

@@ -0,0 +1 @@
export * from "./default-provider"

View File

@@ -1,6 +1,6 @@
import { IAuthModuleService } from "@medusajs/types"
import { Modules } from "@medusajs/modules-sdk"
import { createAuthIdentities } from "../../../__fixtures__/auth-identity"
import { createAuthIdentities } from "../../__fixtures__/auth-identity"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(30000)
@@ -216,7 +216,6 @@ moduleIntegrationTestRunner({
id: "test",
provider: "manual",
entity_id: "test",
scope: "store",
},
])

View File

@@ -0,0 +1,110 @@
import { Modules } from "@medusajs/modules-sdk"
import { IAuthModuleService } from "@medusajs/types"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { resolve } from "path"
let moduleOptions = {
providers: [
{
resolve: resolve(
process.cwd() +
"/integration-tests/__fixtures__/providers/default-provider"
),
options: {
config: {
plaintextpass: {},
},
},
},
],
}
jest.setTimeout(30000)
moduleIntegrationTestRunner({
moduleName: Modules.AUTH,
moduleOptions,
testSuite: ({ service }: SuiteOptions<IAuthModuleService>) =>
describe("Auth Module Service", () => {
beforeEach(async () => {
await service.create({
entity_id: "test@admin.com",
provider: "plaintextpass",
provider_metadata: {
password: "plaintext",
},
})
})
it("it fails if the provider does not exist", async () => {
const err = await service
.authenticate("facebook", {
body: {
email: "test@admin.com",
password: "password",
},
})
.catch((e) => e)
expect(err).toEqual({
success: false,
error: "Could not find a auth provider with id: facebook",
})
})
it("successfully calls the provider for authentication if correct password", async () => {
const result = await service.authenticate("plaintextpass", {
body: {
email: "test@admin.com",
password: "plaintext",
},
})
expect(result).toEqual(
expect.objectContaining({
success: true,
authIdentity: expect.objectContaining({
id: expect.any(String),
entity_id: "test@admin.com",
}),
})
)
})
it("should fail if the password is incorrect", async () => {
const result = await service
.authenticate("plaintextpass", {
body: {
email: "test@admin.com",
password: "incorrect",
},
})
.catch((e) => e)
expect(result).toEqual(
expect.objectContaining({
success: false,
error: "Invalid email or password",
})
)
})
it("successfully create a new entity if nonexistent", async () => {
const result = await service.authenticate("plaintextpass", {
body: {
email: "new@admin.com",
password: "newpass",
},
})
const dbAuthIdentity = await service.retrieve(result.authIdentity.id)
expect(dbAuthIdentity).toEqual(
expect.objectContaining({
id: expect.any(String),
entity_id: "new@admin.com",
})
)
})
}),
})

View File

@@ -1,231 +0,0 @@
import { createAuthIdentities } from "../../../__fixtures__/auth-identity"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { Modules } from "@medusajs/modules-sdk"
import { IAuthModuleService } from "@medusajs/types"
jest.setTimeout(30000)
moduleIntegrationTestRunner({
moduleName: Modules.AUTH,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IAuthModuleService>) => {
describe("AuthIdentity Service", () => {
beforeEach(async () => {
await createAuthIdentities(MikroOrmWrapper.forkManager())
})
describe("list", () => {
it("should list authIdentities", async () => {
const authIdentities = await service.list()
const serialized = JSON.parse(JSON.stringify(authIdentities))
expect(serialized).toEqual([
expect.objectContaining({
provider: "store",
}),
expect.objectContaining({
provider: "manual",
}),
expect.objectContaining({
provider: "manual",
}),
])
})
it("should list authIdentities by id", async () => {
const authIdentities = await service.list({
id: ["test-id"],
})
expect(authIdentities).toEqual([
expect.objectContaining({
id: "test-id",
}),
])
})
it("should list authIdentities by provider_id", async () => {
const authIdentities = await service.list({
provider: "manual",
})
const serialized = JSON.parse(JSON.stringify(authIdentities))
expect(serialized).toEqual([
expect.objectContaining({
id: "test-id",
}),
expect.objectContaining({
id: "test-id-1",
}),
])
})
})
describe("listAndCount", () => {
it("should list authIdentities", async () => {
const [authIdentities, count] = await service.listAndCount()
const serialized = JSON.parse(JSON.stringify(authIdentities))
expect(count).toEqual(3)
expect(serialized).toEqual([
expect.objectContaining({
provider: "store",
}),
expect.objectContaining({
provider: "manual",
}),
expect.objectContaining({
provider: "manual",
}),
])
})
it("should listAndCount authIdentities by provider_id", async () => {
const [authIdentities, count] = await service.listAndCount({
provider: "manual",
})
expect(count).toEqual(2)
expect(authIdentities).toEqual([
expect.objectContaining({
id: "test-id",
}),
expect.objectContaining({
id: "test-id-1",
}),
])
})
})
describe("retrieve", () => {
const id = "test-id"
it("should return an authIdentity for the given id", async () => {
const authIdentity = await service.retrieve(id)
expect(authIdentity).toEqual(
expect.objectContaining({
id,
})
)
})
it("should return authIdentity based on config select param", async () => {
const authIdentity = await service.retrieve(id, {
select: ["id"],
})
const serialized = JSON.parse(JSON.stringify(authIdentity))
expect(serialized).toEqual({
id,
})
})
it("should throw an error when an authIdentity with the given id does not exist", async () => {
let error
try {
await service.retrieve("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual(
"AuthIdentity with id: does-not-exist was not found"
)
})
it("should throw an error when a authIdentityId is not provided", async () => {
let error
try {
await service.retrieve(undefined as unknown as string)
} catch (e) {
error = e
}
expect(error.message).toEqual("authIdentity - id must be defined")
})
})
describe("delete", () => {
it("should delete the authIdentities given an id successfully", async () => {
const id = "test-id"
await service.delete([id])
const authIdentities = await service.list({
id: [id],
})
expect(authIdentities).toHaveLength(0)
})
})
describe("update", () => {
it("should throw an error when a id does not exist", async () => {
let error
try {
await service.update([
{
id: "does-not-exist",
},
])
} catch (e) {
error = e
}
expect(error.message).toEqual(
'AuthIdentity with id "does-not-exist" not found'
)
})
it("should update authIdentity", async () => {
const id = "test-id"
await service.update([
{
id,
provider_metadata: { email: "test@email.com" },
},
])
const [authIdentity] = await service.list({ id: [id] })
expect(authIdentity).toEqual(
expect.objectContaining({
provider_metadata: { email: "test@email.com" },
})
)
})
})
describe("create", () => {
it("should create a authIdentity successfully", async () => {
await service.create([
{
id: "test",
provider: "manual",
entity_id: "test",
scope: "store",
},
])
const [authIdentity] = await service.list({
id: ["test"],
})
expect(authIdentity).toEqual(
expect.objectContaining({
id: "test",
})
)
})
})
})
},
})

View File

@@ -1,41 +0,0 @@
import { Modules } from "@medusajs/modules-sdk"
import { IAuthModuleService } from "@medusajs/types"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(30000)
moduleIntegrationTestRunner({
moduleName: Modules.AUTH,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IAuthModuleService>) => {
describe("AuthModuleService - AuthProvider", () => {
describe("authenticate", () => {
it("authenticate validates that a provider is registered in container", async () => {
const { success, error } = await service.authenticate(
"notRegistered",
{} as any
)
expect(success).toBe(false)
expect(error).toEqual(
"AuthenticationProvider: 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("emailpass", {
authScope: "non-existing",
} as any)
expect(success).toBe(false)
expect(error).toEqual(
`Scope "non-existing" is not valid for provider emailpass`
)
})
})
})
},
})

View File

@@ -1,133 +0,0 @@
import { MedusaModule, Modules } from "@medusajs/modules-sdk"
import { IAuthModuleService } from "@medusajs/types"
import Scrypt from "scrypt-kdf"
import { createAuthIdentities } from "../../../__fixtures__/auth-identity"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(30000)
const seedDefaultData = async (manager) => {
await createAuthIdentities(manager)
}
moduleIntegrationTestRunner({
moduleName: Modules.AUTH,
moduleOptions: {
providers: [
{
name: "emailpass",
scopes: {
admin: {},
store: {},
},
},
],
},
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IAuthModuleService>) => {
describe("AuthModuleService - AuthProvider", () => {
describe("authenticate", () => {
it("authenticate validates that a provider is registered in container", async () => {
const password = "supersecret"
const email = "test@test.com"
const passwordHash = (
await Scrypt.kdf(password, { logN: 15, r: 8, p: 1 })
).toString("base64")
await seedDefaultData(MikroOrmWrapper.forkManager())
await createAuthIdentities(MikroOrmWrapper.forkManager(), [
// Add authenticated user
{
provider: "emailpass",
entity_id: email,
scope: "store",
provider_metadata: {
password: passwordHash,
},
},
])
const res = await service.authenticate("emailpass", {
body: {
email: "test@test.com",
password: password,
},
authScope: "store",
} as any)
expect(res).toEqual({
success: true,
authIdentity: expect.objectContaining({
entity_id: email,
provider_metadata: {},
}),
})
})
it("fails when no password is given", async () => {
await seedDefaultData(MikroOrmWrapper.forkManager())
const res = await service.authenticate("emailpass", {
body: { email: "test@test.com" },
authScope: "store",
} as any)
expect(res).toEqual({
success: false,
error: "Password should be a string",
})
})
it("fails when no email is given", async () => {
await seedDefaultData(MikroOrmWrapper.forkManager())
const res = await service.authenticate("emailpass", {
body: { password: "supersecret" },
authScope: "store",
} as any)
expect(res).toEqual({
success: false,
error: "Email should be a string",
})
})
it("fails with an invalid password", async () => {
const password = "supersecret"
const email = "test@test.com"
const passwordHash = (
await Scrypt.kdf(password, { logN: 15, r: 8, p: 1 })
).toString("base64")
await seedDefaultData(MikroOrmWrapper.forkManager())
await createAuthIdentities(MikroOrmWrapper.forkManager(), [
// Add authenticated user
{
provider: "emailpass",
scope: "store",
entity_id: email,
provider_metadata: {
password_hash: passwordHash,
},
},
])
const res = await service.authenticate("emailpass", {
body: {
email: "test@test.com",
password: "password",
},
authScope: "store",
} as any)
expect(res).toEqual({
success: false,
error: "Invalid email or password",
})
})
})
})
},
})

View File

@@ -48,6 +48,7 @@
"typescript": "^5.1.6"
},
"dependencies": {
"@medusajs/auth-emailpass": "0.0.1",
"@medusajs/modules-sdk": "^1.12.9",
"@medusajs/types": "^1.11.14",
"@medusajs/utils": "^1.11.7",

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>
}
}[]
}

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"
]
}

View File

@@ -5092,10 +5092,24 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/auth-emailpass@0.0.1, @medusajs/auth-emailpass@workspace:packages/modules/providers/auth-emailpass":
version: 0.0.0-use.local
resolution: "@medusajs/auth-emailpass@workspace:packages/modules/providers/auth-emailpass"
dependencies:
"@medusajs/utils": ^1.11.7
cross-env: ^5.2.1
jest: ^25.5.4
rimraf: ^5.0.1
scrypt-kdf: ^2.0.1
typescript: ^4.9.5
languageName: unknown
linkType: soft
"@medusajs/auth@workspace:*, @medusajs/auth@workspace:packages/modules/auth":
version: 0.0.0-use.local
resolution: "@medusajs/auth@workspace:packages/modules/auth"
dependencies:
"@medusajs/auth-emailpass": 0.0.1
"@medusajs/modules-sdk": ^1.12.9
"@medusajs/types": ^1.11.14
"@medusajs/utils": ^1.11.7