diff --git a/.github/workflows/medusa-plugin-auth.yml b/.github/workflows/medusa-plugin-auth.yml new file mode 100644 index 0000000..ab839aa --- /dev/null +++ b/.github/workflows/medusa-plugin-auth.yml @@ -0,0 +1,41 @@ +name: medusa-plugin-auth +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + unit-test: + runs-on: ubuntu-latest + strategy: + matrix: + node-verion: [16.x] + medusajs-version: [1.3.x, 1.4.x, 1.5.x] + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + with: + access_token: ${{ github.token }} + + - name: Checkout + uses: actions/checkout@v2.3.5 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v3.1.1 + with: + node-version: ${{ matrix.node-verion }} + + - name: 'yarn install' + working-directory: ./packages/medusa-plugin-auth + run: yarn + + - name: 'run unit tests' + working-directory: ./packages/medusa-plugin-auth + run: yarn run test:ci + env: + MEDUSAJS_VERSION: ${{ matrix.medusajs-version }} \ No newline at end of file diff --git a/packages/medusa-plugin-auth/README.md b/packages/medusa-plugin-auth/README.md index d57c571..ecb3354 100644 --- a/packages/medusa-plugin-auth/README.md +++ b/packages/medusa-plugin-auth/README.md @@ -55,7 +55,7 @@ Then, in your medusa config plugins collection you can add the following configu failureRedirect: `${process.env.ADMIN_URL}/login`, successRedirect: `${process.env.ADMIN_URL}/`, authPath: "/admin/auth/google", - authCallbackPath: "/admin/auth/google/cb", + authCallbackPath: "/admin/auth/google/cb", expiresIn: "24h" }, @@ -74,6 +74,59 @@ Then, in your medusa config plugins collection you can add the following configu } ``` +Here is the full configuration types + +```typescript + +export type AuthOptions = { + google?: { + clientID: string; + clientSecret: string; + admin?: { + callbackUrl: string; + successRedirect: string; + failureRedirect: string; + authPath: string; + authCallbackPath: string; + /** + * The default verify callback function will be used if this configuration is not specified + */ + verifyCallback?: ( + container: MedusaContainer, + req: Request, + accessToken: string, + refreshToken: string, + profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, + done: (err: null | unknown, data: null | { id: string }) => void + ) => Promise; + + expiresIn?: string; + }; + store?: { + callbackUrl: string; + successRedirect: string; + failureRedirect: string; + authPath: string; + authCallbackPath: string; + /** + * The default verify callback function will be used if this configuration is not specified + */ + verifyCallback?: ( + container: MedusaContainer, + req: Request, + accessToken: string, + refreshToken: string, + profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, + done: (err: null | unknown, data: null | { id: string }) => void + ) => Promise; + + expiresIn?: string; + }; + }; +}; + +``` + Now you can add your Google sign in button in your client with something along the lime of the code bellow ```html diff --git a/packages/medusa-plugin-auth/src/api/index.ts b/packages/medusa-plugin-auth/src/api/index.ts index c200eaa..0c306c4 100644 --- a/packages/medusa-plugin-auth/src/api/index.ts +++ b/packages/medusa-plugin-auth/src/api/index.ts @@ -6,7 +6,7 @@ import loadConfig from '@medusajs/medusa/dist/loaders/config'; import { AUTH_TOKEN_COOKIE_NAME, AuthOptions } from '../types'; import { loadJwtOverrideStrategy } from '../auth-strategies/jwt-override'; import { getGoogleAdminAuthRouter, getGoogleStoreAuthRouter } from '../auth-strategies/google'; -import cors from "cors"; +import cors from 'cors'; export default function (rootDirectory, pluginOptions: AuthOptions): Router[] { const configModule = loadConfig(rootDirectory) as ConfigModule; @@ -32,39 +32,38 @@ function loadRouters(configModule: ConfigModule, options: AuthOptions): Router[] } } - return [...routers, getLogoutRouter(configModule)]; } function getLogoutRouter(configModule: ConfigModule): Router { - const router = Router() + const router = Router(); const logoutHandler = async (req, res) => { if (req.session) { - req.session.jwt = {} - req.session.destroy() + req.session.jwt = {}; + req.session.destroy(); } res.clearCookie(AUTH_TOKEN_COOKIE_NAME); - res.status(200).json({}) - } + res.status(200).json({}); + }; const adminCorsOptions = { origin: configModule.projectConfig.admin_cors.split(','), credentials: true, }; - router.use("/admin/auth", cors(adminCorsOptions)) - router.delete("/admin/auth", wrapHandler(logoutHandler)) + router.use('/admin/auth', cors(adminCorsOptions)); + router.delete('/admin/auth', wrapHandler(logoutHandler)); const storeCorsOptions = { origin: configModule.projectConfig.store_cors.split(','), credentials: true, }; - router.use("/store/auth", cors(storeCorsOptions)) - router.delete("/store/auth", wrapHandler(logoutHandler)) + router.use('/store/auth', cors(storeCorsOptions)); + router.delete('/store/auth', wrapHandler(logoutHandler)); return router; -} \ No newline at end of file +} diff --git a/packages/medusa-plugin-auth/src/auth-strategies/google/__tests__/admin/verify-callback.spec.ts b/packages/medusa-plugin-auth/src/auth-strategies/google/__tests__/admin/verify-callback.spec.ts new file mode 100644 index 0000000..2e54ec6 --- /dev/null +++ b/packages/medusa-plugin-auth/src/auth-strategies/google/__tests__/admin/verify-callback.spec.ts @@ -0,0 +1,70 @@ +import { verifyAdminCallback } from '../../admin'; +import { MedusaContainer } from '@medusajs/medusa/dist/types/global'; + +describe('Google admin strategy verify callback', function () { + const existsEmail = 'exists@test.fr'; + + let container: MedusaContainer; + let req: Request; + let accessToken: string; + let refreshToken: string; + let profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }; + + beforeEach(() => { + profile = { + emails: [{ value: existsEmail }], + }; + + container = { + resolve: (name: string) => { + const container_ = { + userService: { + retrieveByEmail: jest.fn().mockImplementation(async (email: string) => { + if (email === existsEmail) { + return { + id: 'test', + }; + } + + return; + }), + }, + }; + + return container_[name]; + }, + } as MedusaContainer; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should success', async () => { + profile = { + emails: [{ value: existsEmail }], + }; + + const done = (err, data) => { + expect(data).toEqual( + expect.objectContaining({ + id: 'test', + }) + ); + }; + + await verifyAdminCallback(container, req, accessToken, refreshToken, profile, done); + }); + + it('should fail if the user does not exists', async () => { + profile = { + emails: [{ value: 'fake' }], + }; + + const done = (err) => { + expect(err).toEqual(new Error(`Unable to authenticate the user with the email fake`)); + }; + + await verifyAdminCallback(container, req, accessToken, refreshToken, profile, done); + }); +}); diff --git a/packages/medusa-plugin-auth/src/auth-strategies/google/__tests__/store/verify-callback.spec.ts b/packages/medusa-plugin-auth/src/auth-strategies/google/__tests__/store/verify-callback.spec.ts new file mode 100644 index 0000000..7d2eca9 --- /dev/null +++ b/packages/medusa-plugin-auth/src/auth-strategies/google/__tests__/store/verify-callback.spec.ts @@ -0,0 +1,112 @@ +import { verifyStoreCallback } from '../../store'; +import { MedusaContainer } from '@medusajs/medusa/dist/types/global'; +import { ENTITY_METADATA_KEY } from '../../index'; + +describe('Google store strategy verify callback', function () { + const existsEmail = 'exists@test.fr'; + const existsEmailWithMeta = 'exist2s@test.fr'; + + let container: MedusaContainer; + let req: Request; + let accessToken: string; + let refreshToken: string; + let profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }; + + beforeEach(() => { + profile = { + emails: [{ value: existsEmail }], + }; + + container = { + resolve: (name: string): T => { + const container_ = { + manager: { + transaction: function (cb) { + cb(); + }, + }, + customerService: { + withTransaction: function () { + return this; + }, + create: jest.fn().mockImplementation(async () => { + return { id: 'test' }; + }), + retrieveByEmail: jest.fn().mockImplementation(async (email: string) => { + if (email === existsEmail) { + return { + id: 'test', + }; + } + + if (email === existsEmailWithMeta) { + return { + id: 'test2', + metadata: { + [ENTITY_METADATA_KEY]: true, + }, + }; + } + + return; + }), + }, + }; + + return container_[name]; + }, + } as MedusaContainer; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should succeed', async () => { + profile = { + emails: [{ value: existsEmailWithMeta }], + }; + + const done = (err, data) => { + expect(data).toEqual( + expect.objectContaining({ + id: 'test2', + }) + ); + }; + + await verifyStoreCallback(container, req, accessToken, refreshToken, profile, done); + }); + + it('should fail when the customer exists without the metadata', async () => { + profile = { + emails: [{ value: existsEmail }], + }; + + const done = (err) => { + expect(err).toEqual(new Error(`Customer with email ${existsEmail} already exists`)); + }; + + await verifyStoreCallback(container, req, accessToken, refreshToken, profile, done); + }); + + it('should succeed and create a new customer if it has not been found', async () => { + profile = { + emails: [{ value: 'fake' }], + name: { + givenName: 'test', + familyName: 'test', + }, + }; + + const done = (err, data) => { + expect(data).toEqual( + expect.objectContaining({ + id: 'test', + }) + ); + }; + + await verifyStoreCallback(container, req, accessToken, refreshToken, profile, done); + }); +}); diff --git a/packages/medusa-plugin-auth/src/auth-strategies/google/admin.ts b/packages/medusa-plugin-auth/src/auth-strategies/google/admin.ts index 8956b5a..58d98c7 100644 --- a/packages/medusa-plugin-auth/src/auth-strategies/google/admin.ts +++ b/packages/medusa-plugin-auth/src/auth-strategies/google/admin.ts @@ -6,20 +6,25 @@ import { AUTH_TOKEN_COOKIE_NAME, AuthOptions } from '../../types'; import { UserService } from '@medusajs/medusa'; import formatRegistrationName from '@medusajs/medusa/dist/utils/format-registration-name'; import { MedusaError } from 'medusa-core-utils'; -import { generateEntityId } from '@medusajs/medusa/dist/utils'; import { Router } from 'express'; import cors from 'cors'; import { getCookieOptions } from '../../utils/get-cookie-options'; -import { ENTITY_METADATA_KEY } from './index'; const GOOGLE_ADMIN_STRATEGY_NAME = 'google.admin.medusa-auth-plugin'; +/** + * Load the google strategy and attach the given verifyCallback or use the default implementation + * @param container + * @param configModule + * @param google + */ export function loadGoogleAdminStrategy( container: MedusaContainer, configModule: ConfigModule, google: AuthOptions['google'] ): void { - const userService: UserService = container.resolve(formatRegistrationName(`${process.cwd()}/services/user.js`)); + const verifyCallbackFn: AuthOptions['google']['admin']['verifyCallback'] = + google.admin.verifyCallback ?? verifyAdminCallback; passport.use( GOOGLE_ADMIN_STRATEGY_NAME, @@ -30,51 +35,28 @@ export function loadGoogleAdminStrategy( callbackURL: google.admin.callbackUrl, passReqToCallback: true, }, - async function ( + async ( req: Request & { session: { jwt: string } }, accessToken: string, refreshToken: string, profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, - done - ) { - const email = profile.emails[0].value; - - const user = await userService.retrieveByEmail(email).catch(() => void 0); - if (user) { - if (!user.metadata[ENTITY_METADATA_KEY]) { - const err = new MedusaError( - MedusaError.Types.INVALID_DATA, - `User with email ${email} already exists` - ); - return done(err, null); - } else { - return done(null, { id: user.id }); - } - } + done: (err: null | unknown, data: null | { id: string }) => void + ) => { + const done_ = (err: null | unknown, data: null | { id: string }) => { + done(err, data); + }; - await userService - .create( - { - email, - metadata: { - [ENTITY_METADATA_KEY]: true, - }, - first_name: profile?.name.givenName ?? '', - last_name: profile?.name.familyName ?? '', - }, - generateEntityId('temp_pass_') - ) - .then((user) => { - return done(null, { id: user.id }); - }) - .catch((err) => { - return done(err, null); - }); + await verifyCallbackFn(container, req, accessToken, refreshToken, profile, done_); } ) ); } +/** + * Return the router that hold the google admin authentication routes + * @param google + * @param configModule + */ export function getGoogleAdminAuthRouter(google: AuthOptions['google'], configModule: ConfigModule): Router { const router = Router(); @@ -95,20 +77,61 @@ export function getGoogleAdminAuthRouter(google: AuthOptions['google'], configMo }) ); + const callbackHandler = (req, res, next) => { + if (req.user) { + return next(); + } + + const token = jwt.sign({ userId: req.user.id }, configModule.projectConfig.jwt_secret, { + expiresIn: google.admin.expiresIn ?? '24h', + }); + res.cookie(AUTH_TOKEN_COOKIE_NAME, token, getCookieOptions()).redirect(google.admin.successRedirect); + }; + router.get(google.admin.authCallbackPath, cors(adminCorsOptions)); router.get( google.admin.authCallbackPath, + // This one is duplicated to avoid fast connexion which + // can end up in google throwing an error for too fast connexion + callbackHandler, passport.authenticate(GOOGLE_ADMIN_STRATEGY_NAME, { failureRedirect: google.admin.failureRedirect, session: false, }), - (req, res) => { - const token = jwt.sign({ userId: req.user.id }, configModule.projectConfig.jwt_secret, { - expiresIn: google.admin.expiresIn ?? '24h', - }); - res.cookie(AUTH_TOKEN_COOKIE_NAME, token, getCookieOptions()).redirect(google.admin.successRedirect); - } + callbackHandler ); return router; } + +/** + * Default callback to execute when the strategy is called. + * @param container + * @param req + * @param accessToken + * @param refreshToken + * @param profile + * @param done + */ +export async function verifyAdminCallback( + container: MedusaContainer, + req: Request, + accessToken: string, + refreshToken: string, + profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, + done: (err: null | unknown, data: null | { id: string }) => void +): Promise { + const userService: UserService = container.resolve(formatRegistrationName(`${process.cwd()}/services/user.js`)); + const email = profile.emails[0].value; + + const user = await userService.retrieveByEmail(email).catch(() => void 0); + if (!user) { + const err = new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Unable to authenticate the user with the email ${email}` + ); + return done(err, null); + } + + return done(null, { id: user.id }); +} diff --git a/packages/medusa-plugin-auth/src/auth-strategies/google/store.ts b/packages/medusa-plugin-auth/src/auth-strategies/google/store.ts index 1536f48..9beccd3 100644 --- a/packages/medusa-plugin-auth/src/auth-strategies/google/store.ts +++ b/packages/medusa-plugin-auth/src/auth-strategies/google/store.ts @@ -15,15 +15,19 @@ import { ENTITY_METADATA_KEY } from './index'; const GOOGLE_STORE_STRATEGY_NAME = 'google.store.medusa-auth-plugin'; +/** + * Load the google strategy and attach the given verifyCallback or use the default implementation + * @param container + * @param configModule + * @param google + */ export function loadGoogleStoreStrategy( container: MedusaContainer, configModule: ConfigModule, google: AuthOptions['google'] ): void { - const manager: EntityManager = container.resolve('manager'); - const customerService: CustomerService = container.resolve( - formatRegistrationName(`${process.cwd()}/services/customer.js`) - ); + const verifyCallbackFn: AuthOptions['google']['store']['verifyCallback'] = + google.admin.verifyCallback ?? verifyStoreCallback; passport.use( GOOGLE_STORE_STRATEGY_NAME, @@ -39,50 +43,23 @@ export function loadGoogleStoreStrategy( accessToken: string, refreshToken: string, profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, - done + done: (err: null | unknown, data: null | { id: string }) => void ) { - await manager.transaction(async (transactionManager) => { - const email = profile.emails[0].value; - - const customer = await customerService - .withTransaction(transactionManager) - .retrieveByEmail(email) - .catch(() => void 0); + const done_ = (err: null | unknown, data: null | { id: string }) => { + done(err, data); + }; - if (customer) { - if (!customer.metadata[ENTITY_METADATA_KEY]) { - const err = new MedusaError( - MedusaError.Types.INVALID_DATA, - `Customer with email ${email} already exists` - ); - return done(err, null); - } else { - return done(null, { customer_id: customer.id }); - } - } - - await customerService - .withTransaction(transactionManager) - .create({ - email, - metadata: { - [ENTITY_METADATA_KEY]: true, - }, - first_name: profile?.name.givenName ?? '', - last_name: profile?.name.familyName ?? '', - }) - .then((customer) => { - return done(null, { id: customer.id }); - }) - .catch((err) => { - return done(err, null); - }); - }); + await verifyCallbackFn(container, req, accessToken, refreshToken, profile, done_); } ) ); } +/** + * Return the router that hold the google store authentication routes + * @param google + * @param configModule + */ export function getGoogleStoreAuthRouter(google: AuthOptions['google'], configModule: ConfigModule): Router { const router = Router(); @@ -120,3 +97,64 @@ export function getGoogleStoreAuthRouter(google: AuthOptions['google'], configMo return router; } + +/** + * Default callback to execute when the strategy is called. + * @param container + * @param req + * @param accessToken + * @param refreshToken + * @param profile + * @param done + */ +export async function verifyStoreCallback( + container: MedusaContainer, + req: Request, + accessToken: string, + refreshToken: string, + profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, + done: (err: null | unknown, data: null | { id: string }) => void +): Promise { + const manager: EntityManager = container.resolve('manager'); + const customerService: CustomerService = container.resolve( + formatRegistrationName(`${process.cwd()}/services/customer.js`) + ); + + await manager.transaction(async (transactionManager) => { + const email = profile.emails[0].value; + + const customer = await customerService + .withTransaction(transactionManager) + .retrieveByEmail(email) + .catch(() => void 0); + + if (customer) { + if (!customer.metadata || !customer.metadata[ENTITY_METADATA_KEY]) { + const err = new MedusaError( + MedusaError.Types.INVALID_DATA, + `Customer with email ${email} already exists` + ); + return done(err, null); + } else { + return done(null, { id: customer.id }); + } + } + + await customerService + .withTransaction(transactionManager) + .create({ + email, + metadata: { + [ENTITY_METADATA_KEY]: true, + }, + first_name: profile?.name.givenName ?? '', + last_name: profile?.name.familyName ?? '', + }) + .then((customer) => { + return done(null, { id: customer.id }); + }) + .catch((err) => { + return done(err, null); + }); + }); +} diff --git a/packages/medusa-plugin-auth/src/types/index.ts b/packages/medusa-plugin-auth/src/types/index.ts index 9be7160..216d3a3 100644 --- a/packages/medusa-plugin-auth/src/types/index.ts +++ b/packages/medusa-plugin-auth/src/types/index.ts @@ -1,3 +1,5 @@ +import { MedusaContainer } from '@medusajs/medusa/dist/types/global'; + export const AUTH_TOKEN_COOKIE_NAME = 'auth_token'; export type AuthOptions = { @@ -10,6 +12,17 @@ export type AuthOptions = { failureRedirect: string; authPath: string; authCallbackPath: string; + /** + * The default verify callback function will be used if this configuration is not specified + */ + verifyCallback?: ( + container: MedusaContainer, + req: Request, + accessToken: string, + refreshToken: string, + profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, + done: (err: null | unknown, data: null | { id: string }) => void + ) => Promise; expiresIn?: string; }; @@ -19,6 +32,17 @@ export type AuthOptions = { failureRedirect: string; authPath: string; authCallbackPath: string; + /** + * The default verify callback function will be used if this configuration is not specified + */ + verifyCallback?: ( + container: MedusaContainer, + req: Request, + accessToken: string, + refreshToken: string, + profile: { emails: { value: string }[]; name?: { givenName?: string; familyName?: string } }, + done: (err: null | unknown, data: null | { id: string }) => void + ) => Promise; expiresIn?: string; };