feat: improved refresh mechanism

This commit is contained in:
Nevo David 2024-10-18 18:34:10 +07:00
parent 581953ba18
commit b6da5b83e3
19 changed files with 154 additions and 113 deletions

View File

@ -28,6 +28,7 @@ import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/in
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
@ApiTags('Integrations')
@Controller('/integrations')
@ -156,7 +157,12 @@ export class IntegrationsController {
: undefined;
const { codeVerifier, state, url } =
await integrationProvider.generateAuthUrl(refresh, getExternalUrl);
await integrationProvider.generateAuthUrl(getExternalUrl);
if (refresh) {
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 300);
}
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
await ioRedis.set(
`external:${state}`,
@ -311,6 +317,11 @@ export class IntegrationsController {
await ioRedis.del(`external:${body.state}`);
}
const refresh = await ioRedis.get(`refresh:${body.state}`);
if (refresh) {
await ioRedis.del(`refresh:${body.state}`);
}
const {
accessToken,
expiresIn,
@ -319,14 +330,28 @@ export class IntegrationsController {
name,
picture,
username,
} = await integrationProvider.authenticate(
{
code: body.code,
codeVerifier: getCodeVerifier,
refresh: body.refresh,
},
details ? JSON.parse(details) : undefined
);
// eslint-disable-next-line no-async-promise-executor
} = await new Promise<AuthTokenDetails>(async (res) => {
const auth = await integrationProvider.authenticate(
{
code: body.code,
codeVerifier: getCodeVerifier,
refresh: body.refresh,
},
details ? JSON.parse(details) : undefined
);
if (refresh && integrationProvider.reConnect) {
const newAuth = await integrationProvider.reConnect(
auth.id,
refresh,
auth.accessToken
);
return res(newAuth);
}
return res(auth);
});
if (!id) {
throw new Error('Invalid api key');
@ -343,7 +368,7 @@ export class IntegrationsController {
refreshToken,
expiresIn,
username,
integrationProvider.isBetweenSteps,
refresh ? false : integrationProvider.isBetweenSteps,
body.refresh,
+body.timezone,
details

View File

@ -126,12 +126,13 @@ export const LaunchesComponent = () => {
{sortedIntegrations.map((integration) => (
<div
{...(integration.refreshNeeded && {
onClick: refreshChannel(integration),
'data-tooltip-id': 'tooltip',
'data-tooltip-content':
'Channel disconnected, click to reconnect.',
})}
key={integration.id}
className="flex gap-[8px] items-center"
className={clsx("flex gap-[8px] items-center", integration.refreshNeeded && 'cursor-pointer')}
>
<div
className={clsx(

View File

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useMemo } from 'react';
import React, { FC, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { continueProviderList } from '@gitroom/frontend/components/launches/providers/continue-provider/list';

View File

@ -26,6 +26,7 @@ export abstract class SocialAbstract {
let json = '{}';
try {
json = await request.text();
console.log(json);
} catch (err) {
json = '{}';
}

View File

@ -54,7 +54,7 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
return {
url: '',

View File

@ -49,15 +49,13 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
username: '',
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
return {
url: `https://discord.com/oauth2/authorize?client_id=${
process.env.DISCORD_CLIENT_ID
}&permissions=377957124096&response_type=code&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/discord${
refresh ? `?refresh=${refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/discord`
)}&integration_type=0&scope=bot+identify+guilds&state=${state}`,
codeVerifier: makeId(10),
state,

View File

@ -75,15 +75,13 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
);
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
return {
url: `https://dribbble.com/oauth/authorize?client_id=${
process.env.DRIBBBLE_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/dribbble${
refresh ? `?refresh=${refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/dribbble`
)}&response_type=code&scope=${this.scopes.join('+')}&state=${state}`,
codeVerifier: makeId(10),
state,

View File

@ -8,6 +8,7 @@ import {
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { string } from 'yup';
export class FacebookProvider extends SocialAbstract implements SocialProvider {
identifier = 'facebook';
@ -33,16 +34,14 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
return {
url:
'https://www.facebook.com/v20.0/dialog/oauth' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/facebook${
refresh ? `?refresh=${refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/facebook`
)}` +
`&state=${state}` +
`&scope=${this.scopes.join(',')}`,
@ -51,6 +50,27 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
};
}
async reConnect(
id: string,
requiredId: string,
accessToken: string
): Promise<AuthTokenDetails> {
const information = await this.fetchPageInformation(
accessToken,
requiredId
);
return {
id: information.id,
name: information.name,
accessToken: information.access_token,
refreshToken: information.access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: information.picture,
username: information.username,
};
}
async authenticate(params: {
code: string;
codeVerifier: string;
@ -91,22 +111,6 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
.map((p: any) => p.permission);
this.checkScopes(this.scopes, permissions);
if (params.refresh) {
const information = await this.fetchPageInformation(
access_token,
params.refresh
);
return {
id: information.id,
name: information.name,
accessToken: information.access_token,
refreshToken: information.access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: information.picture,
username: information.username,
};
}
const {
id,
name,
@ -174,7 +178,11 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
let finalId = '';
let finalUrl = '';
if ((firstPost?.media?.[0]?.url?.indexOf('mp4') || -2) > -1) {
const { id: videoId, permalink_url, ...all } = await (
const {
id: videoId,
permalink_url,
...all
} = await (
await this.fetch(
`https://graph.facebook.com/v20.0/${id}/videos?access_token=${accessToken}&fields=id,permalink_url`,
{

View File

@ -9,6 +9,7 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { string } from 'yup';
export class InstagramProvider
extends SocialAbstract
@ -39,16 +40,39 @@ export class InstagramProvider
};
}
async generateAuthUrl(refresh?: string) {
async reConnect(
id: string,
requiredId: string,
accessToken: string
): Promise<AuthTokenDetails> {
const findPage = (await this.pages(accessToken)).find(
(p) => p.id === requiredId
);
const information = await this.fetchPageInformation(accessToken, {
id: requiredId,
pageId: findPage?.pageId!,
});
return {
id: information.id,
name: information.name,
accessToken: information.access_token,
refreshToken: information.access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: information.picture,
username: information.username,
};
}
async generateAuthUrl() {
const state = makeId(6);
return {
url:
'https://www.facebook.com/v20.0/dialog/oauth' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/instagram${
refresh ? `?refresh=${refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/instagram`
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
@ -109,26 +133,6 @@ export class InstagramProvider
)
).json();
if (params.refresh) {
const findPage = (await this.pages(access_token)).find(
(p) => p.id === params.refresh
);
const information = await this.fetchPageInformation(access_token, {
id: params.refresh,
pageId: findPage?.pageId!,
});
return {
id: information.id,
name: information.name,
accessToken: information.access_token,
refreshToken: information.access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: information.picture,
username: information.username,
};
}
return {
id,
name,
@ -187,6 +191,7 @@ export class InstagramProvider
)
).json();
console.log(id, name, profile_picture_url, username);
return {
id,
name,
@ -206,7 +211,9 @@ export class InstagramProvider
const medias = await Promise.all(
firstPost?.media?.map(async (m) => {
const caption =
firstPost.media?.length === 1 ? `&caption=${encodeURIComponent(firstPost.message)}` : ``;
firstPost.media?.length === 1
? `&caption=${encodeURIComponent(firstPost.message)}`
: ``;
const isCarousel =
(firstPost?.media?.length || 0) > 1 ? `&is_carousel_item=true` : ``;
const mediaType =

View File

@ -31,7 +31,11 @@ export class LinkedinPageProvider
override async refreshToken(
refresh_token: string
): Promise<AuthTokenDetails> {
const { access_token: accessToken, expires_in, refresh_token: refreshToken } = await (
const {
access_token: accessToken,
expires_in,
refresh_token: refreshToken,
} = await (
await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
@ -77,15 +81,13 @@ export class LinkedinPageProvider
};
}
override async generateAuthUrl(refresh?: string) {
override async generateAuthUrl() {
const state = makeId(6);
const codeVerifier = makeId(30);
const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${
process.env.LINKEDIN_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/linkedin-page${
refresh ? `?refresh=${refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/linkedin-page`
)}&state=${state}&scope=${encodeURIComponent(this.scopes.join(' '))}`;
return {
url,
@ -117,6 +119,24 @@ export class LinkedinPageProvider
}));
}
async reConnect(
id: string,
requiredId: string,
accessToken: string
): Promise<AuthTokenDetails> {
const information = await this.fetchPageInformation(accessToken, requiredId);
return {
id: information.id,
name: information.name,
accessToken: information.access_token,
refreshToken: information.access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: information.picture,
username: information.username,
};
}
async fetchPageInformation(accessToken: string, pageId: string) {
const data = await (
await fetch(
@ -149,9 +169,7 @@ export class LinkedinPageProvider
body.append('code', params.code);
body.append(
'redirect_uri',
`${process.env.FRONTEND_URL}/integrations/social/linkedin-page${
params.refresh ? `?refresh=${params.refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/linkedin-page`
);
body.append('client_id', process.env.LINKEDIN_CLIENT_ID!);
body.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET!);

View File

@ -73,15 +73,13 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
const codeVerifier = makeId(30);
const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${
process.env.LINKEDIN_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/linkedin${
refresh ? `?refresh=${refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/linkedin`
)}&state=${state}&scope=${encodeURIComponent(this.scopes.join(' '))}`;
return {
url,

View File

@ -29,24 +29,20 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
customUrl: string,
state: string,
clientId: string,
url: string,
refresh?: string
url: string
) {
return `${customUrl}/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(
`${url}/integrations/social/mastodon${
refresh ? `?refresh=${refresh}` : ''
}`
`${url}/integrations/social/mastodon`
)}&scope=${this.scopes.join('+')}&state=${state}`;
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
const url = this.generateUrlDynamic(
'https://mastodon.social',
state,
process.env.MASTODON_CLIENT_ID!,
process.env.FRONTEND_URL!,
refresh
process.env.FRONTEND_URL!
);
return {
url,
@ -98,13 +94,11 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
};
}
async authenticate(
params: {
code: string;
codeVerifier: string;
refresh?: string;
}
) {
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
return this.dynamicAuthenticate(
process.env.MASTODON_CLIENT_ID!,
process.env.MASTODON_CLIENT_SECRET!,

View File

@ -67,15 +67,13 @@ export class PinterestProvider
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
return {
url: `https://www.pinterest.com/oauth/?client_id=${
process.env.PINTEREST_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/pinterest${
refresh ? `?refresh=${refresh}` : ''
}`
`${process.env.FRONTEND_URL}/integrations/social/pinterest`
)}&response_type=code&scope=${encodeURIComponent(
'boards:read,boards:write,pins:read,pins:write,user_accounts:read'
)}&state=${state}`,
@ -213,9 +211,7 @@ export class PinterestProvider
}));
try {
const {
id: pId
} = await (
const { id: pId } = await (
await this.fetch('https://api.pinterest.com/v5/pins', {
method: 'POST',
headers: {

View File

@ -32,7 +32,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
username: '',
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
return {
@ -43,9 +43,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
process?.env?.FRONTEND_URL?.indexOf('https') === -1
? 'https://redirectmeto.com/'
: ''
}${process?.env?.FRONTEND_URL}/integrations/social/slack${
refresh ? `?refresh=${refresh}` : ''
}`
}${process?.env?.FRONTEND_URL}/integrations/social/slack`
)}&scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize&state=${state}`,
codeVerifier: makeId(10),
state,

View File

@ -15,8 +15,8 @@ export interface IAuthenticator {
clientInformation?: ClientInformation
): Promise<AuthTokenDetails>;
refreshToken(refreshToken: string): Promise<AuthTokenDetails>;
reConnect?(id: string, requiredId: string, accessToken: string): Promise<AuthTokenDetails>;
generateAuthUrl(
refresh?: string,
clientInformation?: ClientInformation
): Promise<GenerateAuthUrlResponse>;
analytics?(

View File

@ -34,7 +34,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(6);
return {
url:

View File

@ -67,7 +67,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = Math.random().toString(36).substring(2);
return {
@ -79,7 +79,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
? `https://integration.git.sn/integrations/social/tiktok`
: `${process.env.FRONTEND_URL}/integrations/social/tiktok`
}${refresh ? `?refresh=${refresh}` : ''}`
}`
)}` +
`&state=${state}` +
`&response_type=code` +

View File

@ -49,15 +49,14 @@ export class XProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
});
const { url, oauth_token, oauth_token_secret } =
await client.generateAuthLink(
process.env.FRONTEND_URL +
`/integrations/social/x${refresh ? `?refresh=${refresh}` : ''}`,
process.env.FRONTEND_URL + `/integrations/social/x`,
{
authAccessType: 'write',
linkMode: 'authenticate',

View File

@ -82,7 +82,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl(refresh?: string) {
async generateAuthUrl() {
const state = makeId(7);
const { client } = clientAndYoutube();
return {