feat(authentication): Google authentication provider (#6104)
* init * add entity_id and update provider * initial implementation * update lockfile * fix conflicts * add config variables * update types * refactor google provider * re-order methods * fix pr feedback p. 1 * add initial type and update callback authorization * add google provider to integration test * fix feedback * initial implementation (#6171) * initial implementation * remove oauth lib * move abstract authentication provider * shuffle files around * Update packages/authentication/src/migrations/Migration20240122041959.ts Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com> * call verify with token --------- Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import * as defaultProviders from "@providers"
|
||||
|
||||
import {
|
||||
AwilixContainer,
|
||||
ClassOrFunctionReturning,
|
||||
Constructor,
|
||||
Resolver,
|
||||
asClass,
|
||||
} from "awilix"
|
||||
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/types"
|
||||
|
||||
import { AwilixContainer, ClassOrFunctionReturning, Resolver, asClass, asFunction, asValue } from "awilix"
|
||||
|
||||
export default async ({
|
||||
container,
|
||||
}: LoaderOptions<
|
||||
@@ -18,7 +23,9 @@ export default async ({
|
||||
|
||||
for (const provider of providersToLoad) {
|
||||
container.register({
|
||||
[`auth_provider_${provider.PROVIDER}`]: asClass(provider).singleton(),
|
||||
[`auth_provider_${provider.PROVIDER}`]: asClass(
|
||||
provider as Constructor<any>
|
||||
).singleton(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
],
|
||||
"mappedType": "enum"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "jsonb",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "json"
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20240104154451 extends Migration {
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.addSql('create table "auth_provider" ("provider" text not null, "name" text not null, "domain" text check ("domain" in (\'all\', \'store\', \'admin\')) not null default \'all\', "is_active" boolean not null default false, constraint "auth_provider_pkey" primary key ("provider"));');
|
||||
|
||||
this.addSql('create table "auth_user" ("id" text not null, "provider_id" text null, "user_metadata" jsonb null, "app_metadata" jsonb null, "provider_metadata" jsonb null, constraint "auth_user_pkey" primary key ("id"));');
|
||||
|
||||
this.addSql('alter table "auth_user" add constraint "auth_user_provider_id_foreign" foreign key ("provider_id") references "auth_provider" ("provider") on delete cascade;');
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
this.addSql('alter table "auth_user" drop constraint "auth_user_provider_id_foreign";');
|
||||
|
||||
this.addSql('drop table if exists "auth_provider" cascade;');
|
||||
|
||||
this.addSql('drop table if exists "auth_user" cascade;');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20240115092929 extends Migration {
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.addSql('alter table "auth_user" add column "entity_id" text not null;');
|
||||
this.addSql('alter table "auth_user" add constraint "IDX_auth_user_provider_entity_id" unique ("provider_id", "entity_id");');
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
this.addSql('alter table "auth_user" drop constraint "IDX_auth_user_provider_entity_id";');
|
||||
this.addSql('alter table "auth_user" drop column "entity_id";');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20240122041959 extends Migration {
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.addSql('create table if not exists "auth_provider" ("provider" text not null, "name" text not null, "domain" text check ("domain" in (\'all\', \'store\', \'admin\')) not null default \'all\', "config" jsonb null, "is_active" boolean not null default false, constraint "auth_provider_pkey" primary key ("provider"));');
|
||||
|
||||
this.addSql('create table if not exists "auth_user" ("id" text not null, "entity_id" text not null, "provider_id" text null, "user_metadata" jsonb null, "app_metadata" jsonb null, "provider_metadata" jsonb null, constraint "auth_user_pkey" primary key ("id"));');
|
||||
this.addSql('alter table "auth_user" add constraint "IDX_auth_user_provider_entity_id" unique ("provider_id", "entity_id");');
|
||||
|
||||
this.addSql('alter table "auth_user" add constraint if not exists "auth_user_provider_id_foreign" foreign key ("provider_id") references "auth_provider" ("provider") on delete cascade;');
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
this.addSql('alter table "auth_user" drop constraint if exists "auth_user_provider_id_foreign";');
|
||||
|
||||
this.addSql('drop table if exists "auth_provider" cascade;');
|
||||
|
||||
this.addSql('drop table if exists "auth_user" cascade;');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from "@mikro-orm/core"
|
||||
|
||||
import { ProviderDomain } from "../types/repositories/auth-provider"
|
||||
|
||||
type OptionalFields = "domain" | "is_active"
|
||||
type OptionalFields = "domain" | "is_active" | "config"
|
||||
|
||||
@Entity()
|
||||
export default class AuthProvider {
|
||||
@@ -22,6 +23,9 @@ export default class AuthProvider {
|
||||
@Enum({ items: () => ProviderDomain, default: ProviderDomain.ALL })
|
||||
domain: ProviderDomain = ProviderDomain.ALL
|
||||
|
||||
@Property({ columnType: "jsonb", nullable: true })
|
||||
config: Record<string, unknown> | null = null
|
||||
|
||||
@Property({ columnType: "boolean", default: false })
|
||||
is_active = false
|
||||
}
|
||||
|
||||
209
packages/authentication/src/providers/google.ts
Normal file
209
packages/authentication/src/providers/google.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
AbstractAuthenticationModuleProvider,
|
||||
MedusaError,
|
||||
} from "@medusajs/utils"
|
||||
import { AuthProviderService, AuthUserService } from "@services"
|
||||
import jwt, { JwtPayload } from "jsonwebtoken"
|
||||
|
||||
import { AuthProvider } from "@models"
|
||||
import { AuthenticationResponse } from "@medusajs/types"
|
||||
import { AuthorizationCode } from "simple-oauth2"
|
||||
import url from "url"
|
||||
|
||||
type InjectedDependencies = {
|
||||
authUserService: AuthUserService
|
||||
authProviderService: AuthProviderService
|
||||
}
|
||||
|
||||
type AuthenticationInput = {
|
||||
connection: { encrypted: boolean }
|
||||
url: string
|
||||
headers: { host: string }
|
||||
query: Record<string, string>
|
||||
body: Record<string, string>
|
||||
}
|
||||
|
||||
type ProviderConfig = {
|
||||
clientID: string
|
||||
clientSecret: string
|
||||
callbackURL: string
|
||||
}
|
||||
|
||||
class GoogleProvider extends AbstractAuthenticationModuleProvider {
|
||||
public static PROVIDER = "google"
|
||||
public static DISPLAY_NAME = "Google Authentication"
|
||||
|
||||
protected readonly authUserSerivce_: AuthUserService
|
||||
protected readonly authProviderService_: AuthProviderService
|
||||
|
||||
constructor({ authUserService, authProviderService }: InjectedDependencies) {
|
||||
super()
|
||||
|
||||
this.authUserSerivce_ = authUserService
|
||||
this.authProviderService_ = authProviderService
|
||||
}
|
||||
|
||||
private async validateConfig(config: Partial<ProviderConfig>) {
|
||||
if (!config.clientID) {
|
||||
throw new Error("Google clientID is required")
|
||||
}
|
||||
|
||||
if (!config.clientSecret) {
|
||||
throw new Error("Google clientSecret is required")
|
||||
}
|
||||
|
||||
if (!config.callbackURL) {
|
||||
throw new Error("Google callbackUrl is required")
|
||||
}
|
||||
}
|
||||
|
||||
private originalURL(req: AuthenticationInput) {
|
||||
const tls = req.connection.encrypted,
|
||||
host = req.headers.host,
|
||||
protocol = tls ? "https" : "http",
|
||||
path = req.url || ""
|
||||
return protocol + "://" + host + path
|
||||
}
|
||||
|
||||
async getProviderConfig(req: AuthenticationInput): Promise<ProviderConfig> {
|
||||
const { config } = (await this.authProviderService_.retrieve(
|
||||
GoogleProvider.PROVIDER
|
||||
)) as AuthProvider & { config: ProviderConfig }
|
||||
|
||||
this.validateConfig(config || {})
|
||||
|
||||
const { callbackURL } = config
|
||||
|
||||
const parsedCallbackUrl = !url.parse(callbackURL).protocol
|
||||
? url.resolve(this.originalURL(req), callbackURL)
|
||||
: callbackURL
|
||||
|
||||
return { ...config, callbackURL: parsedCallbackUrl }
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
req: AuthenticationInput
|
||||
): Promise<AuthenticationResponse> {
|
||||
if (req.query && req.query.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${req.query.error_description}, read more at: ${req.query.error_uri}`,
|
||||
}
|
||||
}
|
||||
|
||||
let config
|
||||
|
||||
try {
|
||||
config = await this.getProviderConfig(req)
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
let { callbackURL, clientID, clientSecret } = config
|
||||
|
||||
const meta: ProviderConfig = {
|
||||
clientID,
|
||||
callbackURL,
|
||||
clientSecret,
|
||||
}
|
||||
|
||||
const code = (req.query && req.query.code) || (req.body && req.body.code)
|
||||
|
||||
// Redirect to google
|
||||
if (!code) {
|
||||
return this.getRedirect(meta)
|
||||
}
|
||||
|
||||
return await this.validateCallback(code, meta)
|
||||
}
|
||||
|
||||
// abstractable
|
||||
private async validateCallback(
|
||||
code: string,
|
||||
{ clientID, callbackURL, clientSecret }: ProviderConfig
|
||||
) {
|
||||
const client = this.getAuthorizationCodeHandler({ clientID, clientSecret })
|
||||
|
||||
const tokenParams = {
|
||||
code,
|
||||
redirect_uri: callbackURL,
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await client.getToken(tokenParams)
|
||||
|
||||
return await this.verify_(accessToken.token.id_token)
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
// abstractable
|
||||
async verify_(refreshToken: string) {
|
||||
const jwtData = (await jwt.decode(refreshToken, {
|
||||
complete: true,
|
||||
})) as JwtPayload
|
||||
const entity_id = jwtData.payload.email
|
||||
|
||||
let authUser
|
||||
|
||||
try {
|
||||
authUser = await this.authUserSerivce_.retrieveByProviderAndEntityId(
|
||||
entity_id,
|
||||
GoogleProvider.PROVIDER
|
||||
)
|
||||
} catch (error) {
|
||||
if (error.type === MedusaError.Types.NOT_FOUND) {
|
||||
authUser = await this.authUserSerivce_.create([
|
||||
{
|
||||
entity_id,
|
||||
provider_id: GoogleProvider.PROVIDER,
|
||||
user_metadata: jwtData!.payload,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, authUser }
|
||||
}
|
||||
|
||||
// Abstractable
|
||||
private getRedirect({ clientID, callbackURL, clientSecret }: ProviderConfig) {
|
||||
const client = this.getAuthorizationCodeHandler({ clientID, clientSecret })
|
||||
|
||||
const location = client.authorizeURL({
|
||||
redirect_uri: callbackURL,
|
||||
scope: "email profile",
|
||||
})
|
||||
|
||||
return { success: true, location }
|
||||
}
|
||||
|
||||
private getAuthorizationCodeHandler({
|
||||
clientID,
|
||||
clientSecret,
|
||||
}: {
|
||||
clientID: string
|
||||
clientSecret: string
|
||||
}) {
|
||||
const config = {
|
||||
client: {
|
||||
id: clientID,
|
||||
secret: clientSecret,
|
||||
},
|
||||
auth: {
|
||||
// TODO: abstract to not be google specific
|
||||
authorizeHost: "https://accounts.google.com",
|
||||
authorizePath: "/o/oauth2/v2/auth",
|
||||
tokenHost: "https://www.googleapis.com",
|
||||
tokenPath: "/oauth2/v4/token",
|
||||
},
|
||||
}
|
||||
|
||||
return new AuthorizationCode(config)
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleProvider
|
||||
@@ -1 +1,2 @@
|
||||
export { default as UsernamePasswordProvider } from "./username-password"
|
||||
export { default as GoogleProvider } from "./google"
|
||||
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
AbstractAuthenticationModuleProvider,
|
||||
AuthenticationResponse,
|
||||
} from "@medusajs/types"
|
||||
import { AuthenticationResponse } from "@medusajs/types"
|
||||
|
||||
import { AuthUserService } from "@services"
|
||||
import Scrypt from "scrypt-kdf"
|
||||
import { isString } from "@medusajs/utils"
|
||||
import { AbstractAuthenticationModuleProvider, isString } from "@medusajs/utils"
|
||||
|
||||
class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider {
|
||||
public static PROVIDER = "usernamePassword"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AbstractAuthenticationModuleProvider,
|
||||
AuthenticationResponse,
|
||||
AuthenticationTypes,
|
||||
Context,
|
||||
@@ -16,6 +15,7 @@ import { joinerConfig } from "../joiner-config"
|
||||
import { AuthProviderService, AuthUserService } from "@services"
|
||||
|
||||
import {
|
||||
AbstractAuthenticationModuleProvider,
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
MedusaContext,
|
||||
@@ -158,7 +158,7 @@ export default class AuthenticationModuleService<
|
||||
protected async createAuthProviders_(
|
||||
data: any[],
|
||||
@MedusaContext() sharedContext: Context
|
||||
): Promise<AuthenticationTypes.AuthProviderDTO[]> {
|
||||
): Promise<TAuthProvider[]> {
|
||||
return await this.authProviderService_.create(data, sharedContext)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ export default class AuthenticationModuleService<
|
||||
async updateAuthProvider_(
|
||||
data: AuthenticationTypes.UpdateAuthProviderDTO[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<AuthProviderDTO[]> {
|
||||
): Promise<TAuthProvider[]> {
|
||||
return await this.authProviderService_.update(data, sharedContext)
|
||||
}
|
||||
|
||||
@@ -380,15 +380,14 @@ export default class AuthenticationModuleService<
|
||||
await this.retrieveAuthProvider(provider, {})
|
||||
|
||||
registeredProvider = this.getRegisteredAuthenticationProvider(provider)
|
||||
|
||||
|
||||
return await registeredProvider.authenticate(authenticationData)
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async createProvidersOnLoad() {
|
||||
private async createProvidersOnLoad() {
|
||||
const providersToLoad = this.__container__["auth_providers"]
|
||||
|
||||
const providers = await this.authProviderService_.list({
|
||||
|
||||
@@ -5,6 +5,7 @@ export type CreateAuthProviderDTO = {
|
||||
name: string
|
||||
domain?: ProviderDomain
|
||||
is_active?: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type UpdateAuthProviderDTO = {
|
||||
@@ -13,6 +14,7 @@ export type UpdateAuthProviderDTO = {
|
||||
name?: string
|
||||
domain?: ProviderDomain
|
||||
is_active?: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
provider: AuthProvider
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type AuthProviderDTO = {
|
||||
name: string
|
||||
domain: ProviderDomain
|
||||
is_active: boolean
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CreateAuthProviderDTO = {
|
||||
@@ -10,6 +11,7 @@ export type CreateAuthProviderDTO = {
|
||||
name: string
|
||||
domain?: ProviderDomain
|
||||
is_active?: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type UpdateAuthProviderDTO = {
|
||||
@@ -17,6 +19,7 @@ export type UpdateAuthProviderDTO = {
|
||||
name?: string
|
||||
domain?: ProviderDomain
|
||||
is_active?: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export enum ProviderDomain {
|
||||
|
||||
Reference in New Issue
Block a user