feat: add Medusa Cloud OAuth provider (#14395)
* feat: add Medusa Cloud OAuth provider * add Cloud login button * fetch whether cloud auth is enabled through api * allow unregistered to get session * handle existing users * address PR comments * prevent double execution * a few more fixes * fix callback url * fix spelling * refresh session * 200 instead of 201 * only allow cloud identities to create user * fix condition
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { IAuthModuleService } from "@medusajs/types"
|
||||
import jwt from "jsonwebtoken"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
const createMockIdToken = (payload: Record<string, any> = {}) => {
|
||||
return jwt.sign(
|
||||
{
|
||||
sub: "user-123",
|
||||
email: "john@doe.com",
|
||||
email_verified: true,
|
||||
name: "John Doe",
|
||||
given_name: "John",
|
||||
family_name: "Doe",
|
||||
...payload,
|
||||
},
|
||||
"test-secret"
|
||||
)
|
||||
}
|
||||
|
||||
const mockTokenFetch = jest.fn()
|
||||
global.fetch = mockTokenFetch
|
||||
|
||||
const mockCache = new Map()
|
||||
const inMemoryCache = {
|
||||
get: async (key: string) => mockCache.get(key) ?? null,
|
||||
set: async (key: string, data: any, ttl?: number) => {
|
||||
mockCache.set(key, data)
|
||||
},
|
||||
invalidate: async (key: string) => {
|
||||
mockCache.delete(key)
|
||||
},
|
||||
clear: async () => {
|
||||
mockCache.clear()
|
||||
},
|
||||
}
|
||||
|
||||
moduleIntegrationTestRunner<IAuthModuleService>({
|
||||
moduleName: Modules.AUTH,
|
||||
moduleOptions: {
|
||||
cloud: {
|
||||
oauth_authorize_endpoint: "https://medusa.cloud/oauth/authorize",
|
||||
oauth_token_endpoint: "https://medusa.cloud/oauth/token",
|
||||
api_key: "test-api-key",
|
||||
callback_url: "https://store.app/oauth/callback",
|
||||
environment_handle: "test-environment",
|
||||
},
|
||||
},
|
||||
moduleDependencies: [Modules.CACHE],
|
||||
injectedDependencies: {
|
||||
[Modules.CACHE]: inMemoryCache,
|
||||
},
|
||||
testSuite: ({ service }) =>
|
||||
describe("Medusa Cloud Auth provider", () => {
|
||||
afterEach(() => {
|
||||
mockCache.clear()
|
||||
mockTokenFetch.mockReset()
|
||||
})
|
||||
|
||||
describe("authenticate", () => {
|
||||
it("should redirect to authorization URL with default callback URL", async () => {
|
||||
const response = await service.authenticate("cloud", {
|
||||
query: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(response.location).toBeDefined()
|
||||
const query = new URL(response.location!).searchParams
|
||||
expect(query.size).toBe(5)
|
||||
expect(query.get("redirect_uri")).toBe(
|
||||
"https://store.app/oauth/callback"
|
||||
)
|
||||
expect(query.get("client_id")).toBe("test-environment")
|
||||
expect(query.get("response_type")).toBe("code")
|
||||
expect(query.get("scope")).toBe("email profile openid")
|
||||
expect(query.get("state")?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should redirect to authorization URL with overriden callback URL", async () => {
|
||||
const response = await service.authenticate("cloud", {
|
||||
query: {},
|
||||
body: {
|
||||
callback_url: "https://overriden-callback.app/oauth/callback",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(response.location).toBeDefined()
|
||||
const query = new URL(response.location!).searchParams
|
||||
expect(query.size).toBe(5)
|
||||
expect(query.get("redirect_uri")).toBe(
|
||||
"https://overriden-callback.app/oauth/callback"
|
||||
)
|
||||
expect(query.get("client_id")).toBe("test-environment")
|
||||
expect(query.get("response_type")).toBe("code")
|
||||
expect(query.get("scope")).toBe("email profile openid")
|
||||
expect(query.get("state")?.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateCallback", () => {
|
||||
let state: string
|
||||
|
||||
beforeEach(async () => {
|
||||
const response = await service.authenticate("cloud", {
|
||||
query: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(response.location).toBeDefined()
|
||||
const query = new URL(response.location!).searchParams
|
||||
state = query.get("state")!
|
||||
|
||||
mockTokenFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id_token: createMockIdToken(),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it("should validate a valid callback", async () => {
|
||||
const response = await service.validateCallback("cloud", {
|
||||
query: {
|
||||
code: "code1",
|
||||
state: state,
|
||||
},
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
success: true,
|
||||
authIdentity: {
|
||||
provider_identities: [
|
||||
{
|
||||
entity_id: "user-123",
|
||||
provider: "cloud",
|
||||
user_metadata: {
|
||||
email: "john@doe.com",
|
||||
given_name: "John",
|
||||
family_name: "Doe",
|
||||
name: "John Doe",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockTokenFetch.mock.calls[0][0]).toBe(
|
||||
"https://medusa.cloud/oauth/token"
|
||||
)
|
||||
expect(mockTokenFetch.mock.calls[0][1].method).toBe("POST")
|
||||
expect(mockTokenFetch.mock.calls[0][1].headers).toEqual({
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
const body = mockTokenFetch.mock.calls[0][1].body as URLSearchParams
|
||||
expect(body.get("client_id")).toBe("test-environment")
|
||||
expect(body.get("client_secret")).toBe("test-api-key")
|
||||
expect(body.get("code")).toBe("code1")
|
||||
expect(body.get("redirect_uri")).toBe(
|
||||
"https://store.app/oauth/callback"
|
||||
)
|
||||
expect(body.get("grant_type")).toBe("authorization_code")
|
||||
})
|
||||
|
||||
it("should return an error if the code is not provided", async () => {
|
||||
const response = await service.validateCallback("cloud", {
|
||||
query: {
|
||||
state: state,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(false)
|
||||
expect(response.error).toBe("No code provided")
|
||||
})
|
||||
|
||||
it("should return an error if the state is not provided", async () => {
|
||||
const response = await service.validateCallback("cloud", {
|
||||
query: {
|
||||
code: "code1",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(false)
|
||||
expect(response.error).toBe("No state provided, or session expired")
|
||||
})
|
||||
|
||||
it("should return an error if the state doesn't match the stored state", async () => {
|
||||
const response = await service.validateCallback("cloud", {
|
||||
query: {
|
||||
code: "code1",
|
||||
state: "other-state",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(false)
|
||||
expect(response.error).toBe("No state provided, or session expired")
|
||||
})
|
||||
|
||||
it("should return an error if the token exchange does not return an id_token", async () => {
|
||||
mockTokenFetch.mockReset()
|
||||
mockTokenFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
const response = await service.validateCallback("cloud", {
|
||||
query: {
|
||||
code: "code1",
|
||||
state: state,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(false)
|
||||
expect(response.error).toBe("No id_token")
|
||||
})
|
||||
|
||||
it("should return an error if the token exchange does not return a valid JWT id_token", async () => {
|
||||
mockTokenFetch.mockReset()
|
||||
mockTokenFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id_token: "invalid-jwt-token" }),
|
||||
})
|
||||
const response = await service.validateCallback("cloud", {
|
||||
query: {
|
||||
code: "code1",
|
||||
state: state,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(false)
|
||||
expect(response.error).toBe("The id_token is not a valid JWT")
|
||||
})
|
||||
|
||||
it("should return an error if the email is not verified", async () => {
|
||||
mockTokenFetch.mockReset()
|
||||
mockTokenFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id_token: createMockIdToken({ email_verified: false }),
|
||||
}),
|
||||
})
|
||||
const response = await service.validateCallback("cloud", {
|
||||
query: {
|
||||
code: "code1",
|
||||
state: state,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.success).toBe(false)
|
||||
expect(response.error).toBe(
|
||||
"Email not verified, cannot proceed with authentication"
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
moduleIntegrationTestRunner<IAuthModuleService>({
|
||||
moduleName: Modules.AUTH,
|
||||
moduleOptions: {},
|
||||
moduleDependencies: [Modules.CACHE],
|
||||
injectedDependencies: {
|
||||
[Modules.CACHE]: inMemoryCache,
|
||||
},
|
||||
testSuite: ({ service }) =>
|
||||
describe("Medusa Cloud Auth provider - when cloud options are not provided", () => {
|
||||
it("should not enable Medusa Cloud Email provider", async () => {
|
||||
const error = await service
|
||||
.authenticate("cloud", {
|
||||
query: {},
|
||||
body: {},
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining(
|
||||
"Unable to retrieve the auth provider with id: cloud"
|
||||
),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
moduleIntegrationTestRunner<IAuthModuleService>({
|
||||
moduleName: Modules.AUTH,
|
||||
moduleOptions: {
|
||||
cloud: {
|
||||
oauth_authorize_endpoint: "https://medusa.cloud/oauth/authorize",
|
||||
oauth_token_endpoint: "https://medusa.cloud/oauth/token",
|
||||
api_key: "test-api-key",
|
||||
callback_url: "https://store.app/oauth/callback",
|
||||
environment_handle: "test-environment",
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
moduleDependencies: [Modules.CACHE],
|
||||
injectedDependencies: {
|
||||
[Modules.CACHE]: inMemoryCache,
|
||||
},
|
||||
testSuite: ({ service }) =>
|
||||
describe("Medusa Cloud Auth provider - when cloud auth is disabled", () => {
|
||||
it("should not enable Medusa Cloud Email provider", async () => {
|
||||
const error = await service
|
||||
.authenticate("cloud", {
|
||||
query: {},
|
||||
body: {},
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining(
|
||||
"Unable to retrieve the auth provider with id: cloud"
|
||||
),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,14 +1,38 @@
|
||||
import {
|
||||
LoaderOptions,
|
||||
ModuleProvider,
|
||||
ModulesSdkTypes,
|
||||
} from "@medusajs/framework/types"
|
||||
import { asFunction, asValue, Lifetime } from "@medusajs/framework/awilix"
|
||||
import { moduleProviderLoader } from "@medusajs/framework/modules-sdk"
|
||||
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/framework/types"
|
||||
import {
|
||||
AuthIdentifiersRegistrationName,
|
||||
AuthModuleOptions,
|
||||
AuthProviderRegistrationPrefix,
|
||||
} from "@types"
|
||||
import { MedusaCloudAuthService } from "../providers/medusa-cloud-auth"
|
||||
|
||||
const validateCloudOptions = (options: AuthModuleOptions["cloud"]) => {
|
||||
const {
|
||||
oauth_authorize_endpoint,
|
||||
oauth_token_endpoint,
|
||||
environment_handle,
|
||||
sandbox_handle,
|
||||
api_key,
|
||||
callback_url,
|
||||
} = options ?? {}
|
||||
|
||||
if (!environment_handle && !sandbox_handle) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!oauth_authorize_endpoint ||
|
||||
!oauth_token_endpoint ||
|
||||
!api_key ||
|
||||
!callback_url
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const registrationFn = async (klass, container, pluginOptions) => {
|
||||
container.register({
|
||||
@@ -33,8 +57,16 @@ export default async ({
|
||||
(
|
||||
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||
) & { providers: ModuleProvider[] }
|
||||
) &
|
||||
AuthModuleOptions
|
||||
>): Promise<void> => {
|
||||
if (validateCloudOptions(options?.cloud) && !options?.cloud?.disabled) {
|
||||
await registrationFn(MedusaCloudAuthService, container, {
|
||||
options: options?.cloud,
|
||||
id: "cloud",
|
||||
})
|
||||
}
|
||||
|
||||
await moduleProviderLoader({
|
||||
container,
|
||||
providers: options?.providers || [],
|
||||
|
||||
206
packages/modules/auth/src/providers/medusa-cloud-auth.ts
Normal file
206
packages/modules/auth/src/providers/medusa-cloud-auth.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
AuthenticationInput,
|
||||
AuthenticationResponse,
|
||||
AuthIdentityProviderService,
|
||||
Logger,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
AbstractAuthModuleProvider,
|
||||
MedusaError,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { MedusaCloudAuthProviderOptions } from "@types"
|
||||
import crypto from "crypto"
|
||||
import jwt, { type JwtPayload } from "jsonwebtoken"
|
||||
|
||||
type InjectedDependencies = {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export class MedusaCloudAuthService extends AbstractAuthModuleProvider {
|
||||
static identifier = "cloud"
|
||||
static DISPLAY_NAME = "Medusa Cloud Authentication"
|
||||
|
||||
protected config_: MedusaCloudAuthProviderOptions
|
||||
protected logger_: Logger
|
||||
|
||||
constructor(
|
||||
{ logger }: InjectedDependencies,
|
||||
options: MedusaCloudAuthProviderOptions
|
||||
) {
|
||||
// @ts-ignore
|
||||
super(...arguments)
|
||||
this.config_ = options
|
||||
this.logger_ = logger
|
||||
}
|
||||
|
||||
async register(_): Promise<AuthenticationResponse> {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Medusa Cloud does not support registration. Use method `authenticate` instead."
|
||||
)
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
req: AuthenticationInput,
|
||||
authIdentityService: AuthIdentityProviderService
|
||||
): Promise<AuthenticationResponse> {
|
||||
const query: Record<string, string> = req.query ?? {}
|
||||
const body: Record<string, string> = req.body ?? {}
|
||||
|
||||
if (query.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${query.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
const stateKey = crypto.randomBytes(32).toString("hex")
|
||||
const state = {
|
||||
callback_url: body?.callback_url ?? this.config_.callback_url,
|
||||
}
|
||||
|
||||
await authIdentityService.setState(stateKey, state)
|
||||
return this.getRedirect(this.getClientId(), state.callback_url, stateKey)
|
||||
}
|
||||
|
||||
async validateCallback(
|
||||
req: AuthenticationInput,
|
||||
authIdentityService: AuthIdentityProviderService
|
||||
): Promise<AuthenticationResponse> {
|
||||
const query: Record<string, string> = req.query ?? {}
|
||||
const body: Record<string, string> = req.body ?? {}
|
||||
|
||||
if (query.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${query.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
const code = query?.code ?? body?.code
|
||||
if (!code) {
|
||||
return { success: false, error: "No code provided" }
|
||||
}
|
||||
|
||||
const state = await authIdentityService.getState(query?.state as string)
|
||||
if (!state) {
|
||||
return { success: false, error: "No state provided, or session expired" }
|
||||
}
|
||||
|
||||
const clientId = this.getClientId()
|
||||
|
||||
try {
|
||||
const response = await fetch(this.config_.oauth_token_endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: this.config_.api_key,
|
||||
code,
|
||||
redirect_uri: state.callback_url as string,
|
||||
grant_type: "authorization_code",
|
||||
}),
|
||||
}).then((r) => {
|
||||
if (!r.ok) {
|
||||
this.logger_.warn(
|
||||
`Could not exchange token, ${r.status}, ${
|
||||
r.statusText
|
||||
}: response: ${JSON.stringify(r)}`
|
||||
)
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Could not exchange token, ${r.status}, ${r.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
return r.json()
|
||||
})
|
||||
|
||||
const { authIdentity, success, error } = await this.verify_(
|
||||
response.id_token as string,
|
||||
authIdentityService
|
||||
)
|
||||
|
||||
return {
|
||||
success,
|
||||
authIdentity,
|
||||
error,
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
async verify_(
|
||||
idToken: string | undefined,
|
||||
authIdentityService: AuthIdentityProviderService
|
||||
) {
|
||||
if (!idToken) {
|
||||
return { success: false, error: "No id_token" }
|
||||
}
|
||||
|
||||
const jwtData = jwt.decode(idToken, {
|
||||
complete: true,
|
||||
}) as JwtPayload
|
||||
if (!jwtData) {
|
||||
return { success: false, error: "The id_token is not a valid JWT" }
|
||||
}
|
||||
const payload = jwtData.payload
|
||||
|
||||
if (!payload.email_verified) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Email not verified, cannot proceed with authentication"
|
||||
)
|
||||
}
|
||||
|
||||
const entity_id = payload.sub
|
||||
const userMetadata = {
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
picture: payload.picture,
|
||||
given_name: payload.given_name,
|
||||
family_name: payload.family_name,
|
||||
}
|
||||
|
||||
let authIdentity
|
||||
|
||||
try {
|
||||
authIdentity = await authIdentityService.retrieve({
|
||||
entity_id,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.type === MedusaError.Types.NOT_FOUND) {
|
||||
const createdAuthIdentity = await authIdentityService.create({
|
||||
entity_id,
|
||||
user_metadata: userMetadata,
|
||||
})
|
||||
authIdentity = createdAuthIdentity
|
||||
} else {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
authIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
private getRedirect(clientId: string, callbackUrl: string, stateKey: string) {
|
||||
const authUrl = new URL(this.config_.oauth_authorize_endpoint)
|
||||
authUrl.searchParams.set("redirect_uri", callbackUrl)
|
||||
authUrl.searchParams.set("client_id", clientId)
|
||||
authUrl.searchParams.set("response_type", "code")
|
||||
authUrl.searchParams.set("scope", "email profile openid")
|
||||
authUrl.searchParams.set("state", stateKey)
|
||||
|
||||
return { success: true, location: authUrl.toString() }
|
||||
}
|
||||
|
||||
private getClientId() {
|
||||
return this.config_.environment_handle || this.config_.sandbox_handle
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,19 @@ export type AuthModuleOptions = Partial<ModuleServiceInitializeOptions> & {
|
||||
*/
|
||||
options?: Record<string, unknown>
|
||||
}[]
|
||||
/**
|
||||
* Options for the default Medusa Cloud Auth provider
|
||||
* @private
|
||||
*/
|
||||
cloud?: MedusaCloudAuthProviderOptions
|
||||
}
|
||||
|
||||
export interface MedusaCloudAuthProviderOptions {
|
||||
oauth_authorize_endpoint: string
|
||||
oauth_token_endpoint: string
|
||||
environment_handle: string
|
||||
sandbox_handle: string
|
||||
api_key: string
|
||||
callback_url: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user