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:
Philip Korsholm
2024-01-24 09:57:58 +08:00
committed by GitHub
parent c37c82c5b5
commit b3d013940f
24 changed files with 402 additions and 87 deletions

View File

@@ -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(),
})
}

View File

@@ -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",

View File

@@ -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;');
}
}

View File

@@ -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";');
}
}

View File

@@ -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;');
}
}

View File

@@ -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
}

View 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

View File

@@ -1 +1,2 @@
export { default as UsernamePasswordProvider } from "./username-password"
export { default as GoogleProvider } from "./google"

View File

@@ -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"

View File

@@ -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({

View File

@@ -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
}

View File

@@ -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 {