feat: check for scopes

This commit is contained in:
Nevo David 2024-07-09 21:13:47 +07:00
parent 1016dedc15
commit 1e661e56cd
17 changed files with 224 additions and 88 deletions

View File

@ -6,6 +6,7 @@ import {
Param,
Post,
Query,
UseFilters,
} from '@nestjs/common';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto';
@ -23,6 +24,7 @@ import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permis
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { ApiTags } from '@nestjs/swagger';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes';
@ApiTags('Integrations')
@Controller('/integrations')
@ -181,6 +183,7 @@ export class IntegrationsController {
@Post('/social/:integration/connect')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
@UseFilters(new NotEnoughScopesFilter())
async connectSocialMedia(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,

View File

@ -1,3 +1,5 @@
import { HttpStatusCode } from 'axios';
export const dynamic = 'force-dynamic';
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
@ -19,12 +21,16 @@ export default async function Page({
};
}
const { id, inBetweenSteps } = await (
await internalFetch(`/integrations/social/${provider}/connect`, {
method: 'POST',
body: JSON.stringify(searchParams),
})
).json();
const data = await internalFetch(`/integrations/social/${provider}/connect`, {
method: 'POST',
body: JSON.stringify(searchParams),
});
if (data.status === HttpStatusCode.NotAcceptable) {
return redirect(`/launches?scope=missing`);
}
const { inBetweenSteps, id } = await data.json();
if (inBetweenSteps && !searchParams.refresh) {
return redirect(`/launches?added=${provider}&continue=${id}`);

View File

@ -14,13 +14,16 @@ import clsx from 'clsx';
import { useUser } from '../layout/user.context';
import { Menu } from '@gitroom/frontend/components/launches/menu/menu';
import { GeneratorComponent } from '@gitroom/frontend/components/launches/generator/generator';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { Integration } from '@prisma/client';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import { useToaster } from '@gitroom/react/toaster/toaster';
export const LaunchesComponent = () => {
const fetch = useFetch();
const router = useRouter();
const search = useSearchParams();
const toast = useToaster();
const [reload, setReload] = useState(false);
const load = useCallback(async (path: string) => {
@ -88,7 +91,13 @@ export const LaunchesComponent = () => {
);
useEffect(() => {
if (typeof window !== 'undefined' && window.opener) {
if (typeof window === 'undefined') {
return ;
}
if (search.get('scope') === 'missing') {
toast.show('You have to approve all the channel permissions', 'warning');
}
if (window.opener) {
window.close();
}
}, []);

View File

@ -0,0 +1,14 @@
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { Response } from 'express';
import { NotEnoughScopes } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { HttpStatusCode } from 'axios';
@Catch(NotEnoughScopes)
export class NotEnoughScopesFilter implements ExceptionFilter {
catch(exception: NotEnoughScopes, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(HttpStatusCode.NotAcceptable).json({ invalid: true });
}
}

View File

@ -1,5 +1,6 @@
export class RefreshToken {
}
export class RefreshToken {}
export class NotEnoughScopes {}
export abstract class SocialAbstract {
async fetch(url: string, options: RequestInit = {}) {
@ -11,4 +12,25 @@ export abstract class SocialAbstract {
return request;
}
}
checkScopes(required: string[], got: string | string[]) {
console.log(required, got);
if (Array.isArray(got)) {
if (!required.every((scope) => got.includes(scope))) {
throw new NotEnoughScopes();
}
return true;
}
const newGot = decodeURIComponent(got);
const splitType = newGot.indexOf(',') > -1 ? ',' : ' ';
const gotArray = newGot.split(splitType);
if (!required.every((scope) => gotArray.includes(scope))) {
throw new NotEnoughScopes();
}
return true;
}
}

View File

@ -19,6 +19,7 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
identifier = 'dribbble';
name = 'Dribbble';
isBetweenSteps = false;
scopes = ['public', 'upload'];
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const { access_token, expires_in } = await (
@ -33,8 +34,7 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope:
'boards:read,boards:write,pins:read,pins:write,user_accounts:read',
scope: `${this.scopes.join(',')}`,
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/pinterest`,
}),
})
@ -87,7 +87,7 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
`${process.env.FRONTEND_URL}/integrations/social/dribbble${
refresh ? `?refresh=${refresh}` : ''
}`
)}&response_type=code&scope=public+upload&state=${state}`,
)}&response_type=code&scope=${this.scopes.join('+')}&state=${state}`,
codeVerifier: makeId(10),
state,
};
@ -98,7 +98,7 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
codeVerifier: string;
refresh: string;
}) {
const { access_token } = await (
const { access_token, scope } = await (
await this.fetch(
`https://dribbble.com/oauth/token?client_id=${process.env.DRIBBBLE_CLIENT_ID}&client_secret=${process.env.DRIBBBLE_CLIENT_SECRET}&code=${params.code}&redirect_uri=${process.env.FRONTEND_URL}/integrations/social/dribbble`,
{
@ -107,6 +107,8 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
)
).json();
this.checkScopes(this.scopes, scope);
const { id, name, avatar_url, login } = await (
await this.fetch('https://api.dribbble.com/v2/user', {
method: 'GET',

View File

@ -13,7 +13,14 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
identifier = 'facebook';
name = 'Facebook Page';
isBetweenSteps = true;
scopes = [
'pages_show_list',
'business_management',
'pages_manage_posts',
'pages_manage_engagement',
'pages_read_engagement',
'read_insights',
];
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
return {
refreshToken: '',
@ -38,7 +45,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
}`
)}` +
`&state=${state}` +
'&scope=pages_show_list,business_management,pages_manage_posts,pages_manage_engagement,pages_read_engagement,read_insights',
`&scope=${this.scopes.join(',')}`,
codeVerifier: makeId(10),
state,
};
@ -73,6 +80,17 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
)
).json();
const { data } = await (
await this.fetch(
`https://graph.facebook.com/v20.0/me/permissions?access_token=${access_token}`
)
).json();
const permissions = data
.filter((d: any) => d.status === 'granted')
.map((p: any) => p.permission);
this.checkScopes(this.scopes, permissions);
if (params.refresh) {
const information = await this.fetchPageInformation(
access_token,
@ -277,22 +295,24 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
)
).json();
return data?.map((d: any) => ({
label:
d.name === 'page_impressions_unique'
? 'Page Impressions'
: d.name === 'page_post_engagements'
? 'Posts Engagement'
: d.name === 'page_daily_follows'
? 'Page followers'
: d.name === 'page_video_views'
? 'Videos views'
: 'Posts Impressions',
percentageChange: 5,
data: d?.values?.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
})) || [];
return (
data?.map((d: any) => ({
label:
d.name === 'page_impressions_unique'
? 'Page Impressions'
: d.name === 'page_post_engagements'
? 'Posts Engagement'
: d.name === 'page_daily_follows'
? 'Page followers'
: d.name === 'page_video_views'
? 'Videos views'
: 'Posts Impressions',
percentageChange: 5,
data: d?.values?.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
})) || []
);
}
}

View File

@ -18,6 +18,15 @@ export class InstagramProvider
identifier = 'instagram';
name = 'Instagram';
isBetweenSteps = true;
scopes = [
'instagram_basic',
'pages_show_list',
'pages_read_engagement',
'business_management',
'instagram_content_publish',
'instagram_manage_comments',
'instagram_manage_insights',
];
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
return {
@ -43,9 +52,7 @@ export class InstagramProvider
}`
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(
'instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments,instagram_manage_insights'
)}`,
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
codeVerifier: makeId(10),
state,
};
@ -80,6 +87,17 @@ export class InstagramProvider
)
).json();
const { data } = await (
await this.fetch(
`https://graph.facebook.com/v20.0/me/permissions?access_token=${access_token}`
)
).json();
const permissions = data
.filter((d: any) => d.status === 'granted')
.map((p: any) => p.permission);
this.checkScopes(this.scopes, permissions);
const {
id,
name,
@ -343,13 +361,15 @@ export class InstagramProvider
console.log(all);
return data?.map((d: any) => ({
label: d.title,
percentageChange: 5,
data: d.values.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
})) || [];
return (
data?.map((d: any) => ({
label: d.title,
percentageChange: 5,
data: d.values.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
})) || []
);
}
}

View File

@ -7,9 +7,7 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import { number, string } from 'yup';
import dayjs from 'dayjs';
import { writeFileSync } from 'fs';
export class LinkedinPageProvider
extends LinkedinProvider
@ -18,6 +16,15 @@ export class LinkedinPageProvider
override identifier = 'linkedin-page';
override name = 'LinkedIn Page';
override isBetweenSteps = true;
override scopes = [
'openid',
'profile',
'w_member_social',
'r_basicprofile',
'rw_organization_admin',
'w_organization_social',
'r_organization_social',
];
override async refreshToken(
refresh_token: string
@ -76,9 +83,7 @@ export class LinkedinPageProvider
`${process.env.FRONTEND_URL}/integrations/social/linkedin-page${
refresh ? `?refresh=${refresh}` : ''
}`
)}&state=${state}&scope=${encodeURIComponent(
'openid profile w_member_social r_basicprofile rw_organization_admin w_organization_social r_organization_social'
)}`;
)}&state=${state}&scope=${encodeURIComponent(this.scopes.join(' '))}`;
return {
url,
codeVerifier,
@ -152,6 +157,7 @@ export class LinkedinPageProvider
access_token: accessToken,
expires_in: expiresIn,
refresh_token: refreshToken,
scope,
} = await (
await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
@ -162,6 +168,8 @@ export class LinkedinPageProvider
})
).json();
this.checkScopes(this.scopes, scope);
const {
name,
sub: id,

View File

@ -1,5 +1,9 @@
import {
AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, SocialProvider
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import sharp from 'sharp';
@ -7,12 +11,12 @@ import { lookup } from 'mime-types';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
import { removeMarkdown } from '@gitroom/helpers/utils/remove.markdown';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { number, string } from 'yup';
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
identifier = 'linkedin';
name = 'LinkedIn';
isBetweenSteps = false;
scopes = ['openid', 'profile', 'w_member_social', 'r_basicprofile'];
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { access_token: accessToken, refresh_token: refreshToken } = await (
@ -69,9 +73,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
`${process.env.FRONTEND_URL}/integrations/social/linkedin${
refresh ? `?refresh=${refresh}` : ''
}`
)}&state=${state}&scope=${encodeURIComponent(
'openid profile w_member_social r_basicprofile'
)}`;
)}&state=${state}&scope=${encodeURIComponent(this.scopes.join(' '))}`;
return {
url,
codeVerifier,
@ -100,6 +102,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
access_token: accessToken,
expires_in: expiresIn,
refresh_token: refreshToken,
scope,
} = await (
await this.fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
@ -110,6 +113,8 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
})
).json();
this.checkScopes(this.scopes, scope);
const {
name,
sub: id,
@ -380,7 +385,10 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
actor: type === 'personal' ? `urn:li:person:${id}` : `urn:li:organization:${id}`,
actor:
type === 'personal'
? `urn:li:person:${id}`
: `urn:li:organization:${id}`,
object: topPostId,
message: {
text: removeMarkdown({

View File

@ -20,6 +20,13 @@ export class PinterestProvider
identifier = 'pinterest';
name = 'Pinterest';
isBetweenSteps = false;
scopes = [
'boards:read',
'boards:write',
'pins:read',
'pins:write',
'user_accounts:read',
];
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const { access_token, expires_in } = await (
@ -34,8 +41,7 @@ export class PinterestProvider
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope:
'boards:read,boards:write,pins:read,pins:write,user_accounts:read',
scope: this.scopes.join(','),
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/pinterest`,
}),
})
@ -83,7 +89,7 @@ export class PinterestProvider
codeVerifier: string;
refresh: string;
}) {
const { access_token, refresh_token, expires_in } = await (
const { access_token, refresh_token, expires_in, scope } = await (
await this.fetch('https://api.pinterest.com/v5/oauth/token', {
method: 'POST',
headers: {
@ -100,6 +106,8 @@ export class PinterestProvider
})
).json();
this.checkScopes(this.scopes, scope);
const { id, profile_image, username } = await (
await this.fetch('https://api.pinterest.com/v5/user_account', {
method: 'GET',

View File

@ -14,6 +14,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
identifier = 'reddit';
name = 'Reddit';
isBetweenSteps = false;
scopes = ['read', 'identity', 'submit', 'flair'];
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const {
@ -62,9 +63,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
process.env.REDDIT_CLIENT_ID
}&response_type=code&state=${state}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/reddit`
)}&duration=permanent&scope=${encodeURIComponent(
'read identity submit flair'
)}`;
)}&duration=permanent&scope=${encodeURIComponent(this.scopes.join(' '))}`;
return {
url,
codeVerifier,
@ -77,6 +76,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: expiresIn,
scope
} = await (
await this.fetch('https://www.reddit.com/api/v1/access_token', {
method: 'POST',
@ -94,6 +94,8 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
})
).json();
this.checkScopes(this.scopes, scope);
const { name, id, icon_img } = await (
await this.fetch('https://oauth.reddit.com/api/v1/me', {
headers: {

View File

@ -71,4 +71,5 @@ export interface SocialProvider
identifier: string;
name: string;
isBetweenSteps: boolean;
scopes: string[];
}

View File

@ -15,6 +15,12 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
identifier = 'threads';
name = 'Threads';
isBetweenSteps = false;
scopes = [
'threads_basic',
'threads_content_publish',
'threads_manage_replies',
'threads_manage_insights',
];
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
return {
@ -42,9 +48,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
}`
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(
'threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights'
)}`,
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
codeVerifier: makeId(10),
state,
};
@ -214,7 +218,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
const { id: containerId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${id}/threads?text=${firstPost?.message}&media_type=CAROUSEL&children=${medias.join(
`https://graph.threads.net/v1.0/${id}/threads?text=${
firstPost?.message
}&media_type=CAROUSEL&children=${medias.join(
','
)}&access_token=${accessToken}`,
{
@ -304,10 +310,12 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
data?.map((d: any) => ({
label: capitalize(d.name),
percentageChange: 5,
data: d.total_value ? [{total: d.total_value.value, date: dayjs().format('YYYY-MM-DD')}] : d.values.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
data: d.total_value
? [{ total: d.total_value.value, date: dayjs().format('YYYY-MM-DD') }]
: d.values.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
})) || []
);
}

View File

@ -12,6 +12,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
identifier = 'tiktok';
name = 'Tiktok';
isBetweenSteps = false;
scopes = ['user.info.basic', 'video.publish', 'video.upload'];
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const value = {
@ -74,9 +75,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
)}` +
`&state=${state}` +
`&response_type=code` +
`&scope=${encodeURIComponent(
'user.info.basic,video.publish,video.upload'
)}`,
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
codeVerifier: state,
state,
};
@ -99,7 +98,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
: `${process.env.FRONTEND_URL}/integrations/social/tiktok`,
};
const { access_token, refresh_token } = await (
const { access_token, refresh_token, scope } = await (
await this.fetch('https://open.tiktokapis.com/v2/oauth/token/', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@ -109,6 +108,8 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
})
).json();
this.checkScopes(this.scopes, scope);
const {
data: {
user: { avatar_url, display_name, open_id },

View File

@ -15,6 +15,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
identifier = 'x';
name = 'X';
isBetweenSteps = false;
scopes = [];
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const startingClient = new TwitterApi({
@ -55,9 +56,8 @@ export class XProvider extends SocialAbstract implements SocialProvider {
});
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${refresh ? `?refresh=${refresh}` : ''}`,
{
authAccessType: 'write',
linkMode: 'authenticate',

View File

@ -46,6 +46,17 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
identifier = 'youtube';
name = 'YouTube';
isBetweenSteps = false;
scopes = [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtubepartner',
'https://www.googleapis.com/auth/youtubepartner',
'https://www.googleapis.com/auth/yt-analytics.readonly',
];
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { client, oauth2 } = clientAndYoutube();
@ -79,17 +90,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
prompt: 'consent',
state,
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
scope: [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtubepartner',
'https://www.googleapis.com/auth/youtubepartner',
'https://www.googleapis.com/auth/yt-analytics.readonly',
],
scope: this.scopes.slice(0),
}),
codeVerifier: makeId(11),
state,
@ -104,6 +105,9 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
const { client, oauth2 } = clientAndYoutube();
const { tokens } = await client.getToken(params.code);
client.setCredentials(tokens);
const { scopes } = await client.getTokenInfo(tokens.access_token!);
this.checkScopes(this.scopes, scopes);
const user = oauth2(client);
const { data } = await user.userinfo.get();