Revamp auth module to support multiple providers linked to a single auth identity (#7521)

This commit is contained in:
Stevche Radevski authored and GitHub committed 2024-06-05 07:47:16 +00:00
1 parent 20cd6a7b51
commit fafc92b875
23 files changed
+583 -159

No files matched your search

@@ -41,11 +41,15 @@ medusaIntegrationTestRunner({
) )
await authService.create({ await authService.create({
provider_identities: [
{
provider: "emailpass", provider: "emailpass",
entity_id: email, entity_id: email,
provider_metadata: { provider_metadata: {
password: passwordHash, password: passwordHash,
}, },
},
],
}) })
const response = await api const response = await api
@@ -72,11 +76,15 @@ medusaIntegrationTestRunner({
) )
await authService.create({ await authService.create({
provider_identities: [
{
provider: "emailpass", provider: "emailpass",
entity_id: email, entity_id: email,
provider_metadata: { provider_metadata: {
password: passwordHash, password: passwordHash,
}, },
},
],
}) })
const error = await api const error = await api
@@ -12,30 +12,14 @@ export type AuthIdentityDTO = {
id: string id: string
/** /**
* The ID of the provider used to authenticate the user. * The list of provider identities linked to the auth identity.
*/ **/
provider: string provider_identities?: ProviderIdentityDTO[]
/**
* The user's identifier. For example, when using the `emailpass`
* provider, the `entity_id` would be the user's email.
*/
entity_id: string
/** /**
* Holds information related to the actor IDs tied to the auth identity. * Holds information related to the actor IDs tied to the auth identity.
*/ */
app_metadata?: Record<string, unknown> app_metadata?: Record<string, unknown>
/**
* Holds custom data related to the provider in key-value pairs.
*/
provider_metadata?: Record<string, unknown>
/**
* Holds custom data related to the user in key-value pairs.
*/
user_metadata: Record<string, unknown>
} }
/** /**
@@ -50,31 +34,14 @@ export type CreateAuthIdentityDTO = {
id?: string id?: string
/** /**
* The ID of the provider used to authenticate * The list of provider identities linked to the auth identity.
* the user. **/
*/ provider_identities?: CreateProviderIdentityDTO[]
provider: string
/**
* The user's identifier. For example, when using the `emailpass`
* provider, the `entity_id` would be the user's email.
*/
entity_id: string
/** /**
* Holds information related to the actor IDs tied to the auth identity. * Holds information related to the actor IDs tied to the auth identity.
*/ */
app_metadata?: Record<string, unknown> app_metadata?: Record<string, unknown>
/**
* Holds custom data related to the provider in key-value pairs.
*/
provider_metadata?: Record<string, unknown>
/**
* Holds custom data related to the user in key-value pairs.
*/
user_metadata?: Record<string, unknown>
} }
/** /**
@@ -92,6 +59,101 @@ export type UpdateAuthIdentityDTO = {
* Holds information related to the actor IDs tied to the auth identity. * Holds information related to the actor IDs tied to the auth identity.
*/ */
app_metadata?: Record<string, unknown> app_metadata?: Record<string, unknown>
}
/**
* @interface
*
* The provider identity details.
*/
export type ProviderIdentityDTO = {
/**
* The ID of the provider identity.
*/
id: string
/*
* The ID of the provider used to authenticate the user.
*/
provider: string
/**
* The user's identifier. For example, when using the `emailpass`
* provider, the `entity_id` would be the user's email.
*/
entity_id: string
/**
* The auth identity linked to the provider identity.
*/
auth_identity?: AuthIdentityDTO
/**
* Holds custom data related to the provider in key-value pairs.
*/
provider_metadata?: Record<string, unknown>
/**
* Holds custom data related to the user in key-value pairs.
*/
user_metadata?: Record<string, unknown>
}
/**
* @interface
*
* The provider identity to be created.
*/
export type CreateProviderIdentityDTO = {
/**
* The ID of the provider identity.
*/
id?: string
/*
* The ID of the provider used to authenticate the user.
*/
provider: string
/**
* The user's identifier. For example, when using the `emailpass`
* provider, the `entity_id` would be the user's email.
*/
entity_id: string
/**
* The auth identity linked to the provider identity. Needs to be specified if creating a new provider identity directly.
*/
auth_identity_id?: string
/**
* Holds custom data related to the provider in key-value pairs.
*/
provider_metadata?: Record<string, unknown>
/**
* Holds custom data related to the user in key-value pairs.
*/
user_metadata?: Record<string, unknown>
}
/**
* @interface
*
* The provider identity to be created.
*/
export type UpdateProviderIdentityDTO = {
/**
* The ID of the provider identity.
*/
id: string
/**
* The user's identifier. For example, when using the `emailpass`
* provider, the `entity_id` would be the user's email.
*/
entity_id: string
/** /**
* Holds custom data related to the provider in key-value pairs. * Holds custom data related to the provider in key-value pairs.
*/ */
@@ -114,7 +176,17 @@ export interface FilterableAuthIdentityProps
id?: string[] id?: string[]
/** /**
* Filter the auth identities by the ID of their auth provider. * The provider identities to filter the auth identity by.
*/ */
provider?: string[] | string provider_identities?: {
/**
* Filter the provider identities by the ID of the provider identity ID they are linked to.
*/
entity_id?: string
/**
* Filter the provider identities by the provider handle.
*/
provider?: string
}
} }
@@ -1,3 +1,5 @@
import { AuthIdentityDTO } from "./auth-identity"
/** /**
* @interface * @interface
* *
@@ -12,7 +14,7 @@ export type AuthenticationResponse = {
/** /**
* The authenticated user's details. * The authenticated user's details.
*/ */
authIdentity?: any authIdentity?: AuthIdentityDTO
/** /**
* If an error occurs during the authentication process, * If an error occurs during the authentication process,
+5 -4
View File
@@ -2,16 +2,17 @@ import {
AuthIdentityDTO, AuthIdentityDTO,
AuthenticationInput, AuthenticationInput,
AuthenticationResponse, AuthenticationResponse,
CreateAuthIdentityDTO,
} from "./common" } from "./common"
// This interface currently won't allow for linking multiple providers to a single auth entity. That flow is more complex and not supported yet.
export interface AuthIdentityProviderService { export interface AuthIdentityProviderService {
// The provider is injected by the auth identity module // The provider is injected by the auth identity module
retrieve: (selector: { retrieve: (selector: { entity_id: string }) => Promise<AuthIdentityDTO>
create: (data: {
entity_id: string entity_id: string
provider: string provider_metadata?: Record<string, unknown>
user_metadata?: Record<string, unknown>
}) => Promise<AuthIdentityDTO> }) => Promise<AuthIdentityDTO>
create: (data: CreateAuthIdentityDTO) => Promise<AuthIdentityDTO>
} }
/** /**
+6 -7
View File
@@ -10,13 +10,6 @@ import { Context } from "../shared-context"
import { FindConfig } from "../common" import { FindConfig } from "../common"
import { IModuleService } from "../modules-sdk" import { IModuleService } from "../modules-sdk"
/**
* @ignore
*/
export type JWTGenerationOptions = {
expiresIn?: string | number
}
/** /**
* The main service interface for the Auth Module. * The main service interface for the Auth Module.
*/ */
@@ -196,12 +189,16 @@ export interface IAuthModuleService extends IModuleService {
* @example * @example
* const authIdentities = await authModuleService.create([ * const authIdentities = await authModuleService.create([
* { * {
* provider_identities: [{
* provider: "emailpass", * provider: "emailpass",
* entity_id: "user@example.com", * entity_id: "user@example.com",
* }]
* }, * },
* { * {
* provider_identities: [{
* provider: "google", * provider: "google",
* entity_id: "user@gmail.com", * entity_id: "user@gmail.com",
* }]
* }, * },
* ]) * ])
*/ */
@@ -219,8 +216,10 @@ export interface IAuthModuleService extends IModuleService {
* *
* @example * @example
* const authIdentity = await authModuleService.create({ * const authIdentity = await authModuleService.create({
* provider_identities: [{
* provider: "emailpass", * provider: "emailpass",
* entity_id: "user@example.com", * entity_id: "user@example.com",
* }]
* }) * })
*/ */
create( create(
@@ -45,7 +45,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
await service.validateCallback(auth_provider, authData) await service.validateCallback(auth_provider, authData)
const entityIdKey = `${actor_type}_id` const entityIdKey = `${actor_type}_id`
const entityId = authIdentity.app_metadata?.[entityIdKey] const entityId = authIdentity?.app_metadata?.[entityIdKey] as
| string
| undefined
if (success) { if (success) {
const { http } = req.scope.resolve( const { http } = req.scope.resolve(
ContainerRegistrationKeys.CONFIG_MODULE ContainerRegistrationKeys.CONFIG_MODULE
@@ -54,9 +56,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { jwtSecret, jwtExpiresIn } = http const { jwtSecret, jwtExpiresIn } = http
const token = generateJwtToken( const token = generateJwtToken(
{ {
actor_id: entityId, actor_id: entityId ?? "",
actor_type, actor_type,
auth_identity_id: authIdentity.id, auth_identity_id: authIdentity?.id ?? "",
app_metadata: { app_metadata: {
[entityIdKey]: entityId, [entityIdKey]: entityId,
}, },
@@ -57,14 +57,16 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
).projectConfig ).projectConfig
const entityIdKey = `${actor_type}_id` const entityIdKey = `${actor_type}_id`
const entityId = authIdentity.app_metadata?.[entityIdKey] const entityId = authIdentity?.app_metadata?.[entityIdKey] as
| string
| undefined
const { jwtSecret, jwtExpiresIn } = http const { jwtSecret, jwtExpiresIn } = http
const token = generateJwtToken( const token = generateJwtToken(
{ {
actor_id: entityId, actor_id: entityId ?? "",
actor_type, actor_type,
auth_identity_id: authIdentity.id, auth_identity_id: authIdentity?.id ?? "",
app_metadata: { app_metadata: {
[entityIdKey]: entityId, [entityIdKey]: entityId,
}, },
+2 -1
View File
@@ -56,8 +56,9 @@ export default async function ({
process.exit(1) process.exit(1)
} }
// We know the authIdentity is not undefined
await authService.update({ await authService.update({
id: authIdentity.id, id: authIdentity!.id,
app_metadata: { app_metadata: {
user_id: user.id, user_id: user.id,
}, },
@@ -1,34 +1,36 @@
import { IAuthModuleService } from "@medusajs/types"
import { AuthIdentity } from "@models" import { AuthIdentity } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
export async function createAuthIdentities( export async function createAuthIdentities(
manager: SqlEntityManager, service: IAuthModuleService,
userData: any[] = [ userData: any[] = [
{ {
id: "test-id", id: "test-id",
provider_identities: [
{
entity_id: "test-id", entity_id: "test-id",
provider: "manual", provider: "manual",
}, },
],
},
{ {
id: "test-id-1", id: "test-id-1",
provider_identities: [
{
entity_id: "test-id-1", entity_id: "test-id-1",
provider: "manual", provider: "manual",
}, },
],
},
{
provider_identities: [
{ {
entity_id: "test-id-2", entity_id: "test-id-2",
provider: "store", provider: "store",
}, },
],
},
] ]
): Promise<AuthIdentity[]> { ): Promise<AuthIdentity[]> {
const authIdentities: AuthIdentity[] = [] return await service.create(userData)
for (const user of userData) {
const authIdentity = manager.create(AuthIdentity, user)
authIdentities.push(authIdentity)
}
await manager.persistAndFlush(authIdentities)
return authIdentities
} }
@@ -23,10 +23,14 @@ export class AuthServiceFixtures extends AbstractAuthModuleProvider {
try { try {
authIdentity = await service.retrieve({ authIdentity = await service.retrieve({
entity_id: email, entity_id: email,
provider: this.provider,
}) })
if (authIdentity.provider_metadata?.password === password) { // The provider has to be present, guaranteed by the retrieve filter above.
const providerIdentity = authIdentity.provider_identities?.find(
(pi) => pi.provider === this.provider
)!
if (providerIdentity.provider_metadata?.password === password) {
return { return {
success: true, success: true,
authIdentity, authIdentity,
@@ -36,7 +40,6 @@ export class AuthServiceFixtures extends AbstractAuthModuleProvider {
if (error.type === MedusaError.Types.NOT_FOUND) { if (error.type === MedusaError.Types.NOT_FOUND) {
const createdAuthIdentity = await service.create({ const createdAuthIdentity = await service.create({
entity_id: email, entity_id: email,
provider: this.provider,
provider_metadata: { provider_metadata: {
password, password,
}, },
@@ -13,22 +13,31 @@ moduleIntegrationTestRunner({
}: SuiteOptions<IAuthModuleService>) => { }: SuiteOptions<IAuthModuleService>) => {
describe("AuthModuleService - AuthIdentity", () => { describe("AuthModuleService - AuthIdentity", () => {
beforeEach(async () => { beforeEach(async () => {
await createAuthIdentities(MikroOrmWrapper.forkManager()) await createAuthIdentities(service)
}) })
describe("listAuthIdentities", () => { describe("listAuthIdentities", () => {
it("should list authIdentities", async () => { it("should list authIdentities", async () => {
const authIdentities = await service.list() const authIdentities = await service.list(
{},
{ relations: ["provider_identities"] }
)
expect(authIdentities).toEqual([ expect(authIdentities).toEqual([
expect.objectContaining({ expect.objectContaining({
provider: "store", provider_identities: [
expect.objectContaining({ provider: "store" }),
],
}), }),
expect.objectContaining({ expect.objectContaining({
provider: "manual", provider_identities: [
expect.objectContaining({ provider: "manual" }),
],
}), }),
expect.objectContaining({ expect.objectContaining({
provider: "manual", provider_identities: [
expect.objectContaining({ provider: "manual" }),
],
}), }),
]) ])
}) })
@@ -47,7 +56,9 @@ moduleIntegrationTestRunner({
it("should list authIdentities by provider", async () => { it("should list authIdentities by provider", async () => {
const authIdentities = await service.list({ const authIdentities = await service.list({
provider_identities: {
provider: "manual", provider: "manual",
},
}) })
expect(authIdentities).toEqual([ expect(authIdentities).toEqual([
@@ -63,25 +74,34 @@ moduleIntegrationTestRunner({
describe("listAndCountAuthIdentities", () => { describe("listAndCountAuthIdentities", () => {
it("should list and count authIdentities", async () => { it("should list and count authIdentities", async () => {
const [authIdentities, count] = await service.listAndCount() const [authIdentities, count] = await service.listAndCount(
{},
{ relations: ["provider_identities"] }
)
expect(count).toEqual(3) expect(count).toEqual(3)
expect(authIdentities).toEqual([ expect(authIdentities).toEqual([
expect.objectContaining({ expect.objectContaining({
provider: "store", provider_identities: [
expect.objectContaining({ provider: "store" }),
],
}), }),
expect.objectContaining({ expect.objectContaining({
provider: "manual", provider_identities: [
expect.objectContaining({ provider: "manual" }),
],
}), }),
expect.objectContaining({ expect.objectContaining({
provider: "manual", provider_identities: [
expect.objectContaining({ provider: "manual" }),
],
}), }),
]) ])
}) })
it("should listAndCount authIdentities by provider_id", async () => { it("should listAndCount authIdentities by provider_id", async () => {
const [authIdentities, count] = await service.listAndCount({ const [authIdentities, count] = await service.listAndCount({
provider: "manual", provider_identities: { provider: "manual" },
}) })
expect(count).toEqual(2) expect(count).toEqual(2)
@@ -131,7 +151,11 @@ moduleIntegrationTestRunner({
id: "test-id-1", id: "test-id-1",
}) })
) )
expect(authIdentity["password_hash"]).toEqual(undefined) expect(
authIdentity.provider_identities?.[0].provider_metadata?.[
"password_hash"
]
).toEqual(undefined)
}) })
it("should throw an error when a authIdentityId is not provided", async () => { it("should throw an error when a authIdentityId is not provided", async () => {
@@ -196,14 +220,14 @@ moduleIntegrationTestRunner({
await service.update([ await service.update([
{ {
id, id,
provider_metadata: { email: "test@email.com" }, app_metadata: { email: "test@email.com" },
}, },
]) ])
const [authIdentity] = await service.list({ id: [id] }) const [authIdentity] = await service.list({ id: [id] })
expect(authIdentity).toEqual( expect(authIdentity).toEqual(
expect.objectContaining({ expect.objectContaining({
provider_metadata: { email: "test@email.com" }, app_metadata: { email: "test@email.com" },
}) })
) )
}) })
@@ -214,9 +238,13 @@ moduleIntegrationTestRunner({
await service.create([ await service.create([
{ {
id: "test", id: "test",
provider_identities: [
{
provider: "manual", provider: "manual",
entity_id: "test", entity_id: "test",
}, },
],
},
]) ])
const [authIdentity, count] = await service.listAndCount({ const [authIdentity, count] = await service.listAndCount({
@@ -28,11 +28,15 @@ moduleIntegrationTestRunner({
describe("Auth Module Service", () => { describe("Auth Module Service", () => {
beforeEach(async () => { beforeEach(async () => {
await service.create({ await service.create({
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "plaintextpass", provider: "plaintextpass",
provider_metadata: { provider_metadata: {
password: "plaintext", password: "plaintext",
}, },
},
],
}) })
}) })
@@ -65,7 +69,9 @@ moduleIntegrationTestRunner({
success: true, success: true,
authIdentity: expect.objectContaining({ authIdentity: expect.objectContaining({
id: expect.any(String), id: expect.any(String),
entity_id: "test@admin.com", provider_identities: [
expect.objectContaining({ entity_id: "test@admin.com" }),
],
}), }),
}) })
) )
@@ -97,12 +103,17 @@ moduleIntegrationTestRunner({
}, },
}) })
const dbAuthIdentity = await service.retrieve(result.authIdentity.id) const dbAuthIdentity = await service.retrieve(
result.authIdentity?.id!,
{ relations: ["provider_identities"] }
)
expect(dbAuthIdentity).toEqual( expect(dbAuthIdentity).toEqual(
expect.objectContaining({ expect.objectContaining({
id: expect.any(String), id: expect.any(String),
entity_id: "new@admin.com", provider_identities: [
expect.objectContaining({ entity_id: "new@admin.com" }),
],
}) })
) )
}) })
@@ -1,7 +1,68 @@
{ {
"namespaces": ["public"], "namespaces": [
"public"
],
"name": "public", "name": "public",
"tables": [ "tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"app_metadata": {
"name": "app_metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
}
},
"name": "auth_identity",
"schema": "public",
"indexes": [
{
"keyName": "auth_identity_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{ {
"columns": { "columns": {
"id": { "id": {
@@ -31,6 +92,15 @@
"nullable": false, "nullable": false,
"mappedType": "text" "mappedType": "text"
}, },
"auth_identity_id": {
"name": "auth_identity_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"user_metadata": { "user_metadata": {
"name": "user_metadata", "name": "user_metadata",
"type": "jsonb", "type": "jsonb",
@@ -40,15 +110,6 @@
"nullable": true, "nullable": true,
"mappedType": "json" "mappedType": "json"
}, },
"app_metadata": {
"name": "app_metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"provider_metadata": { "provider_metadata": {
"name": "provider_metadata", "name": "provider_metadata",
"type": "jsonb", "type": "jsonb",
@@ -57,28 +118,75 @@
"primary": false, "primary": false,
"nullable": true, "nullable": true,
"mappedType": "json" "mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
} }
}, },
"name": "auth_identity", "name": "provider_identity",
"schema": "public", "schema": "public",
"indexes": [ "indexes": [
{ {
"keyName": "IDX_auth_identity_provider_entity_id", "keyName": "IDX_provider_identity_auth_identity_id",
"columnNames": ["provider", "entity_id"], "columnNames": [],
"composite": true, "composite": false,
"primary": false, "primary": false,
"unique": true "unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_provider_identity_auth_identity_id\" ON \"provider_identity\" (auth_identity_id)"
}, },
{ {
"keyName": "auth_identity_pkey", "keyName": "IDX_provider_identity_provider_entity_id",
"columnNames": ["id"], "columnNames": [],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_provider_identity_provider_entity_id\" ON \"provider_identity\" (entity_id, provider)"
},
{
"keyName": "provider_identity_pkey",
"columnNames": [
"id"
],
"composite": false, "composite": false,
"primary": true, "primary": true,
"unique": true "unique": true
} }
], ],
"checks": [], "checks": [],
"foreignKeys": {} "foreignKeys": {
"provider_identity_auth_identity_id_foreign": {
"constraintName": "provider_identity_auth_identity_id_foreign",
"columnNames": [
"auth_identity_id"
],
"localTableName": "public.provider_identity",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.auth_identity",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
} }
] ]
} }
@@ -5,6 +5,9 @@ export class Migration20240205025928 extends Migration {
this.addSql( 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"));' '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" drop constraint if exists "IDX_auth_identity_provider_entity_id"'
)
this.addSql( this.addSql(
'alter table "auth_identity" add constraint "IDX_auth_identity_provider_entity_id" unique ("provider", "entity_id");' 'alter table "auth_identity" add constraint "IDX_auth_identity_provider_entity_id" unique ("provider", "entity_id");'
) )
@@ -0,0 +1,30 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240529080336 extends Migration {
async up(): Promise<void> {
this.addSql('create table if not exists "provider_identity" ("id" text not null, "entity_id" text not null, "provider" text not null, "auth_identity_id" text not null, "user_metadata" jsonb null, "provider_metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), constraint "provider_identity_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_provider_identity_auth_identity_id" ON "provider_identity" (auth_identity_id);');
this.addSql('CREATE UNIQUE INDEX IF NOT EXISTS "IDX_provider_identity_provider_entity_id" ON "provider_identity" (entity_id, provider);');
this.addSql('alter table if exists "provider_identity" add constraint "provider_identity_auth_identity_id_foreign" foreign key ("auth_identity_id") references "auth_identity" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "auth_identity" add column if not exists "created_at" timestamptz not null default now(), add column "updated_at" timestamptz not null default now();');
this.addSql('alter table if exists "auth_identity" drop constraint if exists "IDX_auth_identity_provider_entity_id";');
this.addSql('alter table if exists "auth_identity" drop column if exists "entity_id";');
this.addSql('alter table if exists "auth_identity" drop column if exists "provider";');
this.addSql('alter table if exists "auth_identity" drop column if exists "user_metadata";');
this.addSql('alter table if exists "auth_identity" drop column if exists "provider_metadata";');
}
async down(): Promise<void> {
this.addSql('drop table if exists "provider_identity" cascade;');
this.addSql('alter table if exists "auth_identity" add column if not exists "entity_id" text not null, add column "provider" text not null, add column "user_metadata" jsonb null, add column "provider_metadata" jsonb null;');
this.addSql('alter table if exists "auth_identity" alter column if exists "app_metadata" type jsonb using ("app_metadata"::jsonb);');
this.addSql('alter table if exists "auth_identity" alter column if exists "app_metadata" set not null;');
this.addSql('alter table if exists "auth_identity" add constraint "IDX_auth_identity_provider_entity_id" unique ("provider", "entity_id");');
}
}
@@ -1,50 +1,45 @@
import { import {
BeforeCreate, BeforeCreate,
Collection,
Entity, Entity,
OnInit, OnInit,
OptionalProps, OneToMany,
PrimaryKey, PrimaryKey,
Property, Property,
Unique,
} from "@mikro-orm/core" } from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils" import { generateEntityId } from "@medusajs/utils"
import ProviderIdentity from "./provider-identity"
type OptionalFields = "provider_metadata" | "app_metadata" | "user_metadata"
@Entity() @Entity()
@Unique({
properties: ["provider", "entity_id"],
name: "IDX_auth_identity_provider_entity_id",
})
export default class AuthIdentity { export default class AuthIdentity {
[OptionalProps]: OptionalFields
@PrimaryKey({ columnType: "text" }) @PrimaryKey({ columnType: "text" })
id!: string id!: string
@Property({ columnType: "text" }) @OneToMany(() => ProviderIdentity, (o) => o.auth_identity)
entity_id: string provider_identities = new Collection<ProviderIdentity>(this)
@Property({ columnType: "text" })
provider: string
@Property({ columnType: "jsonb", nullable: true })
user_metadata: Record<string, unknown> | null
@Property({ columnType: "jsonb", nullable: true }) @Property({ columnType: "jsonb", nullable: true })
app_metadata: Record<string, unknown> | null app_metadata: Record<string, unknown> | null
@Property({ columnType: "jsonb", nullable: true }) @Property({
provider_metadata: Record<string, unknown> | null = null onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@BeforeCreate() @BeforeCreate()
@OnInit()
onCreate() { onCreate() {
this.id = generateEntityId(this.id, "authid") this.id = generateEntityId(this.id, "authid")
} }
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "authid")
}
} }
@@ -1 +1,2 @@
export { default as AuthIdentity } from "./auth-identity" export { default as AuthIdentity } from "./auth-identity"
export { default as ProviderIdentity } from "./provider-identity"
@@ -0,0 +1,84 @@
import {
BeforeCreate,
Entity,
ManyToOne,
OnInit,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import {
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
import AuthIdentity from "./auth-identity"
const providerEntityIdIndexName = "IDX_provider_identity_provider_entity_id"
const providerEntityIdIndexStatement = createPsqlIndexStatementHelper({
name: providerEntityIdIndexName,
tableName: "provider_identity",
columns: ["entity_id", "provider"],
unique: true,
})
const authIdentityIndexName = "IDX_provider_identity_auth_identity_id"
const authIdentityIndexStatement = createPsqlIndexStatementHelper({
name: authIdentityIndexName,
tableName: "provider_identity",
columns: ["auth_identity_id"],
})
@Entity()
@providerEntityIdIndexStatement.MikroORMIndex()
@authIdentityIndexStatement.MikroORMIndex()
export default class ProviderIdentity {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
entity_id: string
@Property({ columnType: "text" })
provider: string
@ManyToOne(() => AuthIdentity, {
columnType: "text",
fieldName: "auth_identity_id",
mapToPk: true,
onDelete: "cascade",
})
auth_identity_id: string
@ManyToOne(() => AuthIdentity, {
persist: false,
})
auth_identity: AuthIdentity
@Property({ columnType: "jsonb", nullable: true })
user_metadata: Record<string, unknown> | null
@Property({ columnType: "jsonb", nullable: true })
provider_metadata: Record<string, unknown> | null = null
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@BeforeCreate()
@OnInit()
onCreate() {
this.id = generateEntityId(this.id, "provid")
this.auth_identity_id ??= this.auth_identity?.id ?? null
}
}
@@ -10,7 +10,7 @@ import {
ModulesSdkTypes, ModulesSdkTypes,
} from "@medusajs/types" } from "@medusajs/types"
import { AuthIdentity } from "@models" import { AuthIdentity, ProviderIdentity } from "@models"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
@@ -21,34 +21,40 @@ import {
ModulesSdkUtils, ModulesSdkUtils,
} from "@medusajs/utils" } from "@medusajs/utils"
import AuthProviderService from "./auth-provider" import AuthProviderService from "./auth-provider"
import { populate } from "dotenv"
type InjectedDependencies = { type InjectedDependencies = {
baseRepository: DAL.RepositoryService baseRepository: DAL.RepositoryService
authIdentityService: ModulesSdkTypes.InternalModuleService<any> authIdentityService: ModulesSdkTypes.InternalModuleService<any>
providerIdentityService: ModulesSdkTypes.InternalModuleService<any>
authProviderService: AuthProviderService authProviderService: AuthProviderService
} }
const generateMethodForModels = [AuthIdentity] const generateMethodForModels = [AuthIdentity, ProviderIdentity]
export default class AuthModuleService< export default class AuthModuleService<
TAuthIdentity extends AuthIdentity = AuthIdentity TAuthIdentity extends AuthIdentity = AuthIdentity,
TProviderIdentity extends ProviderIdentity = ProviderIdentity
> >
extends ModulesSdkUtils.abstractModuleServiceFactory< extends ModulesSdkUtils.abstractModuleServiceFactory<
InjectedDependencies, InjectedDependencies,
AuthTypes.AuthIdentityDTO, AuthTypes.AuthIdentityDTO,
{ {
AuthIdentity: { dto: AuthTypes.AuthIdentityDTO } AuthIdentity: { dto: AuthTypes.AuthIdentityDTO }
ProviderIdentity: { dto: AuthTypes.ProviderIdentityDTO }
} }
>(AuthIdentity, generateMethodForModels, entityNameToLinkableKeysMap) >(AuthIdentity, generateMethodForModels, entityNameToLinkableKeysMap)
implements AuthTypes.IAuthModuleService implements AuthTypes.IAuthModuleService
{ {
protected baseRepository_: DAL.RepositoryService protected baseRepository_: DAL.RepositoryService
protected authIdentityService_: ModulesSdkTypes.InternalModuleService<TAuthIdentity> protected authIdentityService_: ModulesSdkTypes.InternalModuleService<TAuthIdentity>
protected providerIdentityService_: ModulesSdkTypes.InternalModuleService<TProviderIdentity>
protected readonly authProviderService_: AuthProviderService protected readonly authProviderService_: AuthProviderService
constructor( constructor(
{ {
authIdentityService, authIdentityService,
providerIdentityService,
authProviderService, authProviderService,
baseRepository, baseRepository,
}: InjectedDependencies, }: InjectedDependencies,
@@ -60,6 +66,7 @@ export default class AuthModuleService<
this.baseRepository_ = baseRepository this.baseRepository_ = baseRepository
this.authIdentityService_ = authIdentityService this.authIdentityService_ = authIdentityService
this.authProviderService_ = authProviderService this.authProviderService_ = authProviderService
this.providerIdentityService_ = providerIdentityService
} }
__joinerConfig(): ModuleJoinerConfig { __joinerConfig(): ModuleJoinerConfig {
@@ -132,7 +139,7 @@ export default class AuthModuleService<
return await this.authProviderService_.authenticate( return await this.authProviderService_.authenticate(
provider, provider,
authenticationData, authenticationData,
this.getAuthIdentityProviderService() this.getAuthIdentityProviderService(provider)
) )
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
@@ -147,20 +154,29 @@ export default class AuthModuleService<
return await this.authProviderService_.validateCallback( return await this.authProviderService_.validateCallback(
provider, provider,
authenticationData, authenticationData,
this.getAuthIdentityProviderService() this.getAuthIdentityProviderService(provider)
) )
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
} }
} }
getAuthIdentityProviderService(): AuthIdentityProviderService { getAuthIdentityProviderService(
provider: string
): AuthIdentityProviderService {
return { return {
retrieve: async ({ entity_id, provider }) => { retrieve: async ({ entity_id }) => {
const authIdentities = await this.authIdentityService_.list({ const authIdentities = await this.authIdentityService_.list(
{
provider_identities: {
entity_id, entity_id,
provider, provider,
}) },
},
{
relations: ["provider_identities"],
}
)
if (!authIdentities.length) { if (!authIdentities.length) {
throw new MedusaError( throw new MedusaError(
@@ -180,8 +196,26 @@ export default class AuthModuleService<
authIdentities[0] authIdentities[0]
) )
}, },
create: async (data: AuthTypes.CreateAuthIdentityDTO) => {
const createdAuthIdentity = await this.authIdentityService_.create(data) create: async (data: {
entity_id: string
provider_metadata?: Record<string, unknown>
user_metadata?: Record<string, unknown>
}) => {
const normalizedRequest = {
provider_identities: [
{
entity_id: data.entity_id,
provider_metadata: data.provider_metadata,
user_metadata: data.user_metadata,
provider,
},
],
}
const createdAuthIdentity = await this.authIdentityService_.create(
normalizedRequest
)
return await this.baseRepository_.serialize<AuthTypes.AuthIdentityDTO>( return await this.baseRepository_.serialize<AuthTypes.AuthIdentityDTO>(
createdAuthIdentity createdAuthIdentity
@@ -22,7 +22,7 @@ describe("Email password auth provider", () => {
it("return error if email is not passed", async () => { it("return error if email is not passed", async () => {
const resp = await emailpassService.authenticate( const resp = await emailpassService.authenticate(
{ body: { password: "otherpass" } }, { body: { password: "otherpass" } },
{} {} as any
) )
expect(resp).toEqual({ expect(resp).toEqual({
@@ -34,7 +34,7 @@ describe("Email password auth provider", () => {
it("return error if password is not passed", async () => { it("return error if password is not passed", async () => {
const resp = await emailpassService.authenticate( const resp = await emailpassService.authenticate(
{ body: { email: "test@admin.com" } }, { body: { email: "test@admin.com" } },
{} {} as any
) )
expect(resp).toEqual({ expect(resp).toEqual({
@@ -50,18 +50,22 @@ describe("Email password auth provider", () => {
const authServiceSpies = { const authServiceSpies = {
retrieve: jest.fn().mockImplementation(() => { retrieve: jest.fn().mockImplementation(() => {
return { return {
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "emailpass", provider: "emailpass",
provider_metadata: { provider_metadata: {
password: passwordHash.toString("base64"), password: passwordHash.toString("base64"),
}, },
},
],
} }
}), }),
} }
const resp = await emailpassService.authenticate( const resp = await emailpassService.authenticate(
{ body: { email: "test@admin.com", password: "otherpass" } }, { body: { email: "test@admin.com", password: "otherpass" } },
authServiceSpies authServiceSpies as any
) )
expect(authServiceSpies.retrieve).toHaveBeenCalled() expect(authServiceSpies.retrieve).toHaveBeenCalled()
@@ -78,18 +82,22 @@ describe("Email password auth provider", () => {
const authServiceSpies = { const authServiceSpies = {
retrieve: jest.fn().mockImplementation(() => { retrieve: jest.fn().mockImplementation(() => {
return { return {
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "emailpass", provider: "emailpass",
provider_metadata: { provider_metadata: {
password: passwordHash.toString("base64"), password: passwordHash.toString("base64"),
}, },
},
],
} }
}), }),
} }
const resp = await emailpassService.authenticate( const resp = await emailpassService.authenticate(
{ body: { email: "test@admin.com", password: "somepass" } }, { body: { email: "test@admin.com", password: "somepass" } },
authServiceSpies authServiceSpies as any
) )
expect(authServiceSpies.retrieve).toHaveBeenCalled() expect(authServiceSpies.retrieve).toHaveBeenCalled()
@@ -97,9 +105,13 @@ describe("Email password auth provider", () => {
expect.objectContaining({ expect.objectContaining({
success: true, success: true,
authIdentity: expect.objectContaining({ authIdentity: expect.objectContaining({
provider_identities: [
expect.objectContaining({
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider_metadata: {}, provider_metadata: {},
}), }),
],
}),
}) })
) )
}) })
@@ -111,11 +123,15 @@ describe("Email password auth provider", () => {
}), }),
create: jest.fn().mockImplementation(() => { create: jest.fn().mockImplementation(() => {
return { return {
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "emailpass", provider: "emailpass",
provider_metadata: { provider_metadata: {
password: "somehash", password: "somehash",
}, },
},
],
} }
}), }),
} }
@@ -128,7 +144,7 @@ describe("Email password auth provider", () => {
expect(authServiceSpies.retrieve).toHaveBeenCalled() expect(authServiceSpies.retrieve).toHaveBeenCalled()
expect(authServiceSpies.create).toHaveBeenCalled() expect(authServiceSpies.create).toHaveBeenCalled()
expect(resp.authIdentity).toEqual( expect(resp.authIdentity?.provider_identities?.[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider_metadata: {}, provider_metadata: {},
@@ -4,6 +4,7 @@ import {
AuthenticationResponse, AuthenticationResponse,
AuthenticationInput, AuthenticationInput,
AuthIdentityProviderService, AuthIdentityProviderService,
AuthIdentityDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
AbstractAuthModuleProvider, AbstractAuthModuleProvider,
@@ -53,12 +54,11 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
error: "Email should be a string", error: "Email should be a string",
} }
} }
let authIdentity let authIdentity: AuthIdentityDTO | undefined
try { try {
authIdentity = await authIdentityService.retrieve({ authIdentity = await authIdentityService.retrieve({
entity_id: email, entity_id: email,
provider: this.provider,
}) })
} catch (error) { } catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) { if (error.type === MedusaError.Types.NOT_FOUND) {
@@ -67,14 +67,16 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
const createdAuthIdentity = await authIdentityService.create({ const createdAuthIdentity = await authIdentityService.create({
entity_id: email, entity_id: email,
provider: this.provider,
provider_metadata: { provider_metadata: {
password: passwordHash.toString("base64"), password: passwordHash.toString("base64"),
}, },
}) })
const copy = JSON.parse(JSON.stringify(createdAuthIdentity)) const copy = JSON.parse(JSON.stringify(createdAuthIdentity))
delete copy.provider_metadata?.password const providerIdentity = copy.provider_identities?.find(
(pi) => pi.provider === this.provider
)!
delete providerIdentity.provider_metadata?.password
return { return {
success: true, success: true,
@@ -85,7 +87,10 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
return { success: false, error: error.message } return { success: false, error: error.message }
} }
const passwordHash = authIdentity.provider_metadata?.password const providerIdentity = authIdentity.provider_identities?.find(
(pi) => pi.provider === this.provider
)!
const passwordHash = providerIdentity.provider_metadata?.password
if (isString(passwordHash)) { if (isString(passwordHash)) {
const buf = Buffer.from(passwordHash as string, "base64") const buf = Buffer.from(passwordHash as string, "base64")
@@ -93,7 +98,10 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
if (success) { if (success) {
const copy = JSON.parse(JSON.stringify(authIdentity)) const copy = JSON.parse(JSON.stringify(authIdentity))
delete copy.provider_metadata!.password const providerIdentity = copy.provider_identities?.find(
(pi) => pi.provider === this.provider
)!
delete providerIdentity.provider_metadata?.password
return { return {
success, success,
@@ -158,8 +158,12 @@ describe("Google auth provider", () => {
}), }),
create: jest.fn().mockImplementation(() => { create: jest.fn().mockImplementation(() => {
return { return {
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "google", provider: "google",
},
],
} }
}), }),
} }
@@ -177,9 +181,13 @@ describe("Google auth provider", () => {
success: true, success: true,
successRedirectUrl: baseUrl, successRedirectUrl: baseUrl,
authIdentity: { authIdentity: {
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "google", provider: "google",
}, },
],
},
}) })
}) })
@@ -187,8 +195,12 @@ describe("Google auth provider", () => {
const authServiceSpies = { const authServiceSpies = {
retrieve: jest.fn().mockImplementation(() => { retrieve: jest.fn().mockImplementation(() => {
return { return {
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "google", provider: "google",
},
],
} }
}), }),
create: jest.fn().mockImplementation(() => { create: jest.fn().mockImplementation(() => {
@@ -209,9 +221,13 @@ describe("Google auth provider", () => {
success: true, success: true,
successRedirectUrl: baseUrl, successRedirectUrl: baseUrl,
authIdentity: { authIdentity: {
provider_identities: [
{
entity_id: "test@admin.com", entity_id: "test@admin.com",
provider: "google", provider: "google",
}, },
],
},
}) })
}) })
}) })
@@ -130,13 +130,11 @@ export class GoogleAuthService extends AbstractAuthModuleProvider {
try { try {
authIdentity = await authIdentityService.retrieve({ authIdentity = await authIdentityService.retrieve({
entity_id, entity_id,
provider: this.provider,
}) })
} catch (error) { } catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) { if (error.type === MedusaError.Types.NOT_FOUND) {
const createdAuthIdentity = await authIdentityService.create({ const createdAuthIdentity = await authIdentityService.create({
entity_id, entity_id,
provider: this.provider,
user_metadata: userMetadata, user_metadata: userMetadata,
}) })
authIdentity = createdAuthIdentity authIdentity = createdAuthIdentity