feat: login change and appsumo

This commit is contained in:
Nevo David 2026-03-03 17:42:17 +07:00
parent 681e6d4cb4
commit 0d7e54023c
16 changed files with 428 additions and 103 deletions

View file

@ -36,6 +36,15 @@ import { EnterpriseController } from '@gitroom/backend/api/routes/enterprise.con
import { OAuthAppController } from '@gitroom/backend/api/routes/oauth-app.controller';
import { ApprovedAppsController } from '@gitroom/backend/api/routes/approved-apps.controller';
import { OAuthController, OAuthAuthorizedController } from '@gitroom/backend/api/routes/oauth.controller';
import { AppSumoController } from '@gitroom/backend/api/routes/appsumo.controller';
import { AppSumoService } from '@gitroom/nestjs-libraries/services/appsumo.service';
import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager';
import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider';
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider';
import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider';
import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider';
import { AppSumoProvider } from '@gitroom/backend/services/auth/providers/appsumo.provider';
const authenticatedController = [
UsersController,
@ -61,6 +70,7 @@ const authenticatedController = [
controllers: [
RootController,
StripeController,
AppSumoController,
AuthController,
PublicController,
MonitorController,
@ -72,6 +82,7 @@ const authenticatedController = [
providers: [
AuthService,
StripeService,
AppSumoService,
OpenaiService,
ExtractContentService,
AuthMiddleware,
@ -82,6 +93,13 @@ const authenticatedController = [
TrackService,
ShortLinkService,
Nowpayments,
AuthProviderManager,
GithubProvider,
GoogleProvider,
FarcasterProvider,
WalletProvider,
OauthProvider,
AppSumoProvider,
],
get exports() {
return [...this.imports, ...this.providers];

View file

@ -0,0 +1,40 @@
import {
Controller,
HttpException,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import { AppSumoService } from '@gitroom/nestjs-libraries/services/appsumo.service';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('AppSumo')
@Controller('/appsumo')
export class AppSumoController {
constructor(private readonly _appSumoService: AppSumoService) {}
@Post('/')
async webhook(@Req() req: RawBodyRequest<Request>) {
// @ts-ignore
const timestamp = req.headers['x-appsumo-timestamp'] as string;
// @ts-ignore
const signature = req.headers['x-appsumo-signature'] as string;
if (!timestamp || !signature) {
throw new HttpException('Missing signature headers', 401);
}
this._appSumoService.validateSignature(
req.rawBody!,
timestamp,
signature
);
try {
const payload = JSON.parse(req.rawBody!.toString());
return this._appSumoService.handleWebhook(payload);
} catch (e) {
throw new HttpException(e, 500);
}
}
}

View file

@ -5,7 +5,7 @@ import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service';
import { ProvidersFactory } from '@gitroom/backend/services/auth/providers/providers.factory';
import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager';
import dayjs from 'dayjs';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';
@ -18,7 +18,8 @@ export class AuthService {
private _userService: UsersService,
private _organizationService: OrganizationService,
private _notificationService: NotificationService,
private _emailService: EmailService
private _emailService: EmailService,
private _providerManager: AuthProviderManager
) {}
async canRegister(provider: string) {
if (
@ -136,7 +137,7 @@ export class AuthService {
ip: string,
userAgent: string
) {
const providerInstance = ProvidersFactory.loadProvider(provider);
const providerInstance = this._providerManager.getProvider(provider);
const providerUser = await providerInstance.getUser(body.providerToken);
if (!providerUser) {
@ -174,6 +175,12 @@ export class AuthService {
await NewsletterService.register(providerUser.email);
try {
await providerInstance.postRegistration(body.providerToken, create.id);
} catch (err) {
// Don't fail registration if postRegistration fails
}
return create.users[0].user;
}
@ -277,16 +284,12 @@ export class AuthService {
}
oauthLink(provider: string, query?: any) {
const providerInstance = ProvidersFactory.loadProvider(
provider as Provider
);
const providerInstance = this._providerManager.getProvider(provider);
return providerInstance.generateLink(query);
}
async checkExists(provider: string, code: string) {
const providerInstance = ProvidersFactory.loadProvider(
provider as Provider
);
const providerInstance = this._providerManager.getProvider(provider);
const token = await providerInstance.getToken(code);
const user = await providerInstance.getUser(token);
if (!user) {

View file

@ -1,7 +1,34 @@
export interface ProvidersInterface {
generateLink(query?: any): Promise<string> | string;
getToken(code: string): Promise<string>;
getUser(
import { Injectable } from '@nestjs/common';
export abstract class AuthProviderAbstract {
abstract generateLink(query?: any): Promise<string> | string;
abstract getToken(code: string): Promise<string>;
abstract getUser(
providerToken: string
): Promise<{ email: string; id: string }> | false;
async postRegistration(
providerToken: string,
orgId: string
): Promise<void> {}
}
export interface AuthProviderParams {
provider: string;
}
export function AuthProvider(params: AuthProviderParams) {
return function (target: any) {
Injectable()(target);
const existingMetadata =
Reflect.getMetadata('auth-provider', AuthProviderAbstract) || [];
existingMetadata.push({ target, provider: params.provider });
Reflect.defineMetadata(
'auth-provider',
existingMetadata,
AuthProviderAbstract
);
};
}

View file

@ -0,0 +1,114 @@
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
const APPSUMO_OPENID_BASE = 'https://appsumo.com/openid';
const APPSUMO_TIER_MAP: Record<number, 'STANDARD' | 'TEAM' | 'PRO'> = {
1: 'STANDARD',
2: 'TEAM',
3: 'PRO',
};
@AuthProvider({ provider: 'APPSUMO' })
export class AppSumoProvider extends AuthProviderAbstract {
constructor(private _subscriptionService: SubscriptionService) {
super();
}
generateLink() {
const params = new URLSearchParams({
client_id: process.env.APPSUMO_CLIENT_ID!,
redirect_uri: `${process.env.FRONTEND_URL}/settings`,
response_type: 'code',
scope: 'openid',
});
return `${APPSUMO_OPENID_BASE}/authorize/?${params.toString()}`;
}
async getToken(code: string) {
const clientId = process.env.APPSUMO_CLIENT_ID;
const clientSecret = process.env.APPSUMO_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return '';
}
const tokenResponse = await fetch(`${APPSUMO_OPENID_BASE}/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: `${process.env.FRONTEND_URL}/settings`,
}),
});
if (!tokenResponse.ok) {
return '';
}
const { access_token } = await tokenResponse.json();
return access_token || '';
}
async getUser(accessToken: string) {
if (!accessToken) {
return { id: '', email: '' };
}
const response = await fetch(
`${APPSUMO_OPENID_BASE}/license_key/?access_token=${accessToken}`,
);
if (!response.ok) {
return { id: '', email: '' };
}
const data = await response.json();
if (!data.license_key) {
return { id: '', email: '' };
}
return {
id: String(`appsumo_${data.license_key}`),
email: String(`appsumo_${data.license_key}`),
};
}
async postRegistration(accessToken: string, orgId: string) {
if (!accessToken) {
return;
}
const response = await fetch(
`${APPSUMO_OPENID_BASE}/license_key/?access_token=${accessToken}`,
);
if (!response.ok) {
return;
}
const data = await response.json();
const billing = APPSUMO_TIER_MAP[data.tier] || 'STANDARD';
await this._subscriptionService.createOrUpdateSubscription(
false,
data.license_key,
data.license_key,
pricing[billing].channel!,
billing,
'YEARLY',
null,
data.license_key,
orgId
);
}
}

View file

@ -1,11 +1,15 @@
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
const client = new NeynarAPIClient({
apiKey: process.env.NEYNAR_SECRET_KEY || '00000000-000-0000-000-000000000000',
});
export class FarcasterProvider implements ProvidersInterface {
@AuthProvider({ provider: 'FARCASTER' })
export class FarcasterProvider extends AuthProviderAbstract {
generateLink() {
return '';
}
@ -29,11 +33,6 @@ export class FarcasterProvider implements ProvidersInterface {
};
}
// const { client, oauth2 } = clientAndYoutube();
// client.setCredentials({ access_token: providerToken });
// const user = oauth2(client);
// const { data } = await user.userinfo.get();
return {
id: String('farcaster_' + status.fid),
email: String('farcaster_' + status.fid),

View file

@ -1,6 +1,10 @@
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
export class GithubProvider implements ProvidersInterface {
@AuthProvider({ provider: 'GITHUB' })
export class GithubProvider extends AuthProviderAbstract {
generateLink(): string {
return `https://github.com/login/oauth/authorize?client_id=${
process.env.GITHUB_CLIENT_ID

View file

@ -1,7 +1,9 @@
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
const clientAndYoutube = () => {
const client = new google.auth.OAuth2({
@ -31,7 +33,8 @@ const clientAndYoutube = () => {
return { client, youtube, oauth2, youtubeAnalytics };
};
export class GoogleProvider implements ProvidersInterface {
@AuthProvider({ provider: 'GOOGLE' })
export class GoogleProvider extends AuthProviderAbstract {
generateLink() {
const state = 'login';
const { client } = clientAndYoutube();

View file

@ -1,66 +1,56 @@
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
export class OauthProvider implements ProvidersInterface {
private readonly authUrl: string;
private readonly baseUrl: string;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly frontendUrl: string;
private readonly tokenUrl: string;
private readonly userInfoUrl: string;
constructor() {
@AuthProvider({ provider: 'GENERIC' })
export class OauthProvider extends AuthProviderAbstract {
private getConfig() {
const {
POSTIZ_OAUTH_AUTH_URL,
POSTIZ_OAUTH_CLIENT_ID,
POSTIZ_OAUTH_CLIENT_SECRET,
POSTIZ_OAUTH_TOKEN_URL,
POSTIZ_OAUTH_URL,
POSTIZ_OAUTH_USERINFO_URL,
FRONTEND_URL,
} = process.env;
if (!POSTIZ_OAUTH_USERINFO_URL)
throw new Error(
'POSTIZ_OAUTH_USERINFO_URL environment variable is not set'
);
if (!POSTIZ_OAUTH_URL)
throw new Error('POSTIZ_OAUTH_URL environment variable is not set');
if (!POSTIZ_OAUTH_TOKEN_URL)
throw new Error('POSTIZ_OAUTH_TOKEN_URL environment variable is not set');
if (!POSTIZ_OAUTH_CLIENT_ID)
throw new Error('POSTIZ_OAUTH_CLIENT_ID environment variable is not set');
if (!POSTIZ_OAUTH_CLIENT_SECRET)
throw new Error(
'POSTIZ_OAUTH_CLIENT_SECRET environment variable is not set'
);
if (!POSTIZ_OAUTH_AUTH_URL)
throw new Error('POSTIZ_OAUTH_AUTH_URL environment variable is not set');
if (!FRONTEND_URL)
throw new Error('FRONTEND_URL environment variable is not set');
if (
!POSTIZ_OAUTH_USERINFO_URL ||
!POSTIZ_OAUTH_TOKEN_URL ||
!POSTIZ_OAUTH_CLIENT_ID ||
!POSTIZ_OAUTH_CLIENT_SECRET ||
!POSTIZ_OAUTH_AUTH_URL ||
!FRONTEND_URL
) {
throw new Error('POSTIZ_OAUTH environment variables are not set');
}
this.authUrl = POSTIZ_OAUTH_AUTH_URL;
this.baseUrl = POSTIZ_OAUTH_URL;
this.clientId = POSTIZ_OAUTH_CLIENT_ID;
this.clientSecret = POSTIZ_OAUTH_CLIENT_SECRET;
this.frontendUrl = FRONTEND_URL;
this.tokenUrl = POSTIZ_OAUTH_TOKEN_URL;
this.userInfoUrl = POSTIZ_OAUTH_USERINFO_URL;
return {
authUrl: POSTIZ_OAUTH_AUTH_URL,
clientId: POSTIZ_OAUTH_CLIENT_ID,
clientSecret: POSTIZ_OAUTH_CLIENT_SECRET,
tokenUrl: POSTIZ_OAUTH_TOKEN_URL,
userInfoUrl: POSTIZ_OAUTH_USERINFO_URL,
frontendUrl: FRONTEND_URL,
};
}
generateLink(): string {
const { authUrl, clientId, frontendUrl } = this.getConfig();
const params = new URLSearchParams({
client_id: this.clientId,
client_id: clientId,
scope: 'openid profile email',
response_type: 'code',
redirect_uri: `${this.frontendUrl}/settings`,
redirect_uri: `${frontendUrl}/settings`,
});
return `${this.authUrl}?${params.toString()}`;
return `${authUrl}?${params.toString()}`;
}
async getToken(code: string): Promise<string> {
const response = await fetch(`${this.tokenUrl}`, {
const { tokenUrl, clientId, clientSecret, frontendUrl } = this.getConfig();
const response = await fetch(`${tokenUrl}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@ -68,10 +58,10 @@ export class OauthProvider implements ProvidersInterface {
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.clientId,
client_secret: this.clientSecret,
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: `${this.frontendUrl}/settings`,
redirect_uri: `${frontendUrl}/settings`,
}),
});
@ -85,7 +75,8 @@ export class OauthProvider implements ProvidersInterface {
}
async getUser(access_token: string): Promise<{ email: string; id: string }> {
const response = await fetch(`${this.userInfoUrl}`, {
const { userInfoUrl } = this.getConfig();
const response = await fetch(`${userInfoUrl}`, {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/json',

View file

@ -1,24 +0,0 @@
import { Provider } from '@prisma/client';
import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider';
import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider';
import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider';
export class ProvidersFactory {
static loadProvider(provider: Provider): ProvidersInterface {
switch (provider) {
case Provider.GITHUB:
return new GithubProvider();
case Provider.GOOGLE:
return new GoogleProvider();
case Provider.FARCASTER:
return new FarcasterProvider();
case Provider.WALLET:
return new WalletProvider();
case Provider.GENERIC:
return new OauthProvider();
}
}
}

View file

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { AuthProviderAbstract } from '@gitroom/backend/services/auth/providers.interface';
@Injectable()
export class AuthProviderManager {
constructor(private _moduleRef: ModuleRef) {}
getProvider(provider: string): AuthProviderAbstract {
const metadata =
Reflect.getMetadata('auth-provider', AuthProviderAbstract) || [];
const found = metadata.find(
(m: any) => m.provider === provider
);
if (!found) {
throw new Error(`Auth provider ${provider} not found`);
}
return this._moduleRef.get(found.target, { strict: false });
}
}

View file

@ -1,16 +1,17 @@
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { randomBytes } from 'crypto';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import bs58 from 'bs58';
import nacl from 'tweetnacl';
function hexToUint8Array(hex) {
// Remove any potential "0x" prefix
if (hex.startsWith('0x')) {
hex = hex.slice(2);
}
// Ensure the hex string has an even length
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string. It must have an even length.');
}
@ -19,16 +20,15 @@ function hexToUint8Array(hex) {
const uint8Array = new Uint8Array(byteLength);
for (let i = 0; i < byteLength; i++) {
// Get two characters from the hex string
const byteHex = hex.substr(i * 2, 2);
// Parse the two characters as a hexadecimal number
uint8Array[i] = parseInt(byteHex, 16);
}
return uint8Array;
}
export class WalletProvider implements ProvidersInterface {
@AuthProvider({ provider: 'WALLET' })
export class WalletProvider extends AuthProviderAbstract {
async generateLink(params: { publicKey: string }) {
if (!params.publicKey) {
return;

View file

@ -923,6 +923,7 @@ enum Provider {
FARCASTER
WALLET
GENERIC
APPSUMO
}
enum Role {

View file

@ -132,7 +132,7 @@ export class SubscriptionRepository {
identifier: string,
customerId: string,
totalChannels: number,
billing: 'STANDARD' | 'PRO',
billing: 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE',
period: 'MONTHLY' | 'YEARLY',
cancelAt: number | null,
code?: string,
@ -197,6 +197,18 @@ export class SubscriptionRepository {
}
}
getSubscriptionByIdentifier(identifier: string) {
return this._subscription.model.subscription.findFirst({
where: {
identifier,
deletedAt: null,
},
include: {
organization: true,
},
});
}
getSubscription(organizationId: string) {
return this._subscription.model.subscription.findFirst({
where: {

View file

@ -56,7 +56,7 @@ export class SubscriptionService {
async modifySubscription(
customerId: string,
totalChannels: number,
billing: 'FREE' | 'STANDARD' | 'PRO'
billing: 'FREE' | 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE'
) {
if (!customerId) {
return false;
@ -121,7 +121,7 @@ export class SubscriptionService {
identifier: string,
customerId: string,
totalChannels: number,
billing: 'STANDARD' | 'PRO',
billing: 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE',
period: 'MONTHLY' | 'YEARLY',
cancelAt: number | null,
code?: string,
@ -154,6 +154,10 @@ export class SubscriptionService {
);
}
getSubscriptionByIdentifier(identifier: string) {
return this._subscriptionRepository.getSubscriptionByIdentifier(identifier);
}
async getSubscription(organizationId: string) {
return this._subscriptionRepository.getSubscription(organizationId);
}

View file

@ -0,0 +1,110 @@
import { HttpException, Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
const APPSUMO_TIER_MAP: Record<number, 'STANDARD' | 'TEAM' | 'PRO'> = {
1: 'STANDARD',
2: 'TEAM',
3: 'PRO',
};
export interface AppSumoWebhookPayload {
license_key: string;
event: 'purchase' | 'activate' | 'upgrade' | 'downgrade' | 'deactivate' | 'migrate';
license_status: string;
event_timestamp: number;
created_at: number;
tier: number;
prev_license_key?: string;
test?: boolean;
}
@Injectable()
export class AppSumoService {
constructor(private _subscriptionService: SubscriptionService) {}
validateSignature(
rawBody: Buffer,
timestamp: string,
signature: string
) {
const apiKey = process.env.APPSUMO_API_KEY;
if (!apiKey) {
throw new HttpException('AppSumo API key not configured', 500);
}
const message = timestamp + rawBody.toString();
const expectedSignature = crypto
.createHmac('sha256', apiKey)
.update(message)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
throw new HttpException('Invalid signature', 401);
}
}
async handleWebhook(payload: AppSumoWebhookPayload) {
if (payload.test) {
return { success: true, event: payload.event };
}
switch (payload.event) {
case 'purchase':
case 'activate':
return { success: true, event: payload.event };
case 'upgrade':
return this.handleUpgradeOrDowngrade(payload);
case 'downgrade':
return this.handleUpgradeOrDowngrade(payload);
case 'deactivate':
return this.handleDeactivate(payload);
default:
return { success: true, event: payload.event };
}
}
private async handleUpgradeOrDowngrade(payload: AppSumoWebhookPayload) {
const identifier = payload.prev_license_key || payload.license_key;
const subscription =
await this._subscriptionService.getSubscriptionByIdentifier(identifier);
if (subscription) {
const billing = APPSUMO_TIER_MAP[payload.tier] || 'STANDARD';
await this._subscriptionService.createOrUpdateSubscription(
false,
payload.license_key,
payload.license_key,
pricing[billing].channel!,
billing,
'YEARLY',
null,
payload.license_key,
subscription.organizationId
);
}
return { success: true, event: payload.event };
}
private async handleDeactivate(payload: AppSumoWebhookPayload) {
const subscription =
await this._subscriptionService.getSubscriptionByIdentifier(
payload.license_key
);
if (subscription) {
await this._subscriptionService.deleteSubscription(
subscription.organization.paymentId || subscription.organizationId
);
}
return { success: true, event: payload.event };
}
}