Add authorization with notion

This commit is contained in:
Hongbo Wu 2024-03-04 17:30:05 +08:00
parent 07f771c45f
commit 94db3e0de7
7 changed files with 115 additions and 30 deletions

View file

@ -2,6 +2,7 @@ import axios from 'axios'
import cors from 'cors'
import express from 'express'
import { env } from '../env'
import { getIntegrationClient } from '../services/integrations'
import { getClaimsByToken } from '../utils/auth'
import { corsConfig } from '../utils/corsConfig'
import { logger } from '../utils/logger'
@ -10,10 +11,9 @@ export function integrationRouter() {
const router = express.Router()
// request token from pocket
router.post(
'/pocket/auth',
'/:name/auth',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
logger.info('pocket/request-token')
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const token = (req.cookies.auth as string) || req.headers.authorization
const claims = await getClaimsByToken(token)
@ -21,37 +21,19 @@ export function integrationRouter() {
return res.status(401).send('UNAUTHORIZED')
}
const consumerKey = env.pocket.consumerKey
const redirectUri = `${env.client.url}/settings/integrations`
const integrationClient = getIntegrationClient(req.params.name)
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const state = req.body.state as string
try {
// make a POST request to Pocket to get a request token
const response = await axios.post<{ code: string }>(
'https://getpocket.com/v3/oauth/request',
{
consumer_key: consumerKey,
redirect_uri: redirectUri,
},
{
headers: {
'Content-Type': 'application/json',
'X-Accept': 'application/json',
},
}
)
const { code } = response.data
const redirectUri = await integrationClient.auth(state)
// redirect the user to Pocket to authorize the request token
res.redirect(
`https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent(
`?pocketToken=${code}&state=${state}`
)}`
)
res.redirect(redirectUri)
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error(error.response)
} else {
logger.error('pocket/request-token exception:', error)
logger.error(error)
}
res.redirect(

View file

@ -2,16 +2,20 @@ import { DeepPartial, FindOptionsWhere } from 'typeorm'
import { Integration } from '../../entity/integration'
import { authTrx } from '../../repository'
import { IntegrationClient } from './integration'
import { NotionClient } from './notion'
import { PocketClient } from './pocket'
import { ReadwiseClient } from './readwise'
const integrations: IntegrationClient[] = [
new ReadwiseClient(),
new PocketClient(),
new NotionClient(),
]
export const getIntegrationClient = (name: string): IntegrationClient => {
const service = integrations.find((s) => s.name === name)
const service = integrations.find(
(s) => s.name.toLowerCase() === name.toLowerCase()
)
if (!service) {
throw new Error(`Integration client not found: ${name}`)
}

View file

@ -24,5 +24,7 @@ export interface IntegrationClient {
accessToken(token: string): Promise<string | null>
auth(state: string): Promise<string>
export(token: string, items: LibraryItem[]): Promise<boolean>
}

View file

@ -0,0 +1,54 @@
import axios from 'axios'
import { env } from '../../env'
import { logger } from '../../utils/logger'
import { IntegrationClient } from './integration'
export class NotionClient implements IntegrationClient {
name = 'notion'
apiUrl = 'https://api.notion.com/v1'
headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
}
accessToken = async (code: string): Promise<string | null> => {
const authUrl = `${this.apiUrl}/oauth/token`
try {
// encode in base 64
const encoded = Buffer.from(
`${env.notion.clientId}:${env.notion.clientSecret}`
).toString('base64')
const response = await axios.post<{ access_token: string }>(
authUrl,
{
grant_type: 'authorization_code',
code,
},
{
headers: {
authorization: `Basic ${encoded}`,
...this.headers,
},
timeout: 5000, // 5 seconds
}
)
return response.data.access_token
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error(error.response)
} else {
logger.error(error)
}
return null
}
}
async auth(state: string): Promise<string> {
return Promise.resolve(env.notion.authUrl)
}
export = () => {
throw new Error('Method not implemented.')
}
}

View file

@ -39,4 +39,27 @@ export class PocketClient implements IntegrationClient {
export = async (): Promise<boolean> => {
return Promise.resolve(false)
}
async auth(state: string) {
const consumerKey = env.pocket.consumerKey
const redirectUri = `${env.client.url}/settings/integrations`
// make a POST request to Pocket to get a request token
const response = await axios.post<{ code: string }>(
`${this.apiUrl}/oauth/request`,
{
consumer_key: consumerKey,
redirect_uri: redirectUri,
},
{
headers: this.headers,
timeout: 5000, // 5 seconds
}
)
const { code } = response.data
return `https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent(
`?pocketToken=${code}&state=${state}`
)}`
}
}

View file

@ -57,17 +57,23 @@ export class ReadwiseClient implements IntegrationClient {
export = async (token: string, items: LibraryItem[]): Promise<boolean> => {
let result = true
const highlights = items.flatMap(this.itemToReadwiseHighlight)
const highlights = items.flatMap(this._itemToReadwiseHighlight)
// If there are no highlights, we will skip the sync
if (highlights.length > 0) {
result = await this.syncWithReadwise(token, highlights)
result = await this._syncWithReadwise(token, highlights)
}
return result
}
itemToReadwiseHighlight = (item: LibraryItem): ReadwiseHighlight[] => {
auth(state: string): Promise<string> {
throw new Error('Method not implemented.')
}
private _itemToReadwiseHighlight = (
item: LibraryItem
): ReadwiseHighlight[] => {
const category = item.siteName === 'Twitter' ? 'tweets' : 'articles'
return item.highlights
?.map((highlight) => {
@ -93,7 +99,7 @@ export class ReadwiseClient implements IntegrationClient {
.filter((highlight) => highlight !== undefined) as ReadwiseHighlight[]
}
syncWithReadwise = async (
private _syncWithReadwise = async (
token: string,
highlights: ReadwiseHighlight[]
): Promise<boolean> => {

View file

@ -113,6 +113,11 @@ export interface BackendEnv {
mq: redisConfig
cache: redisConfig
}
notion: {
clientId: string
clientSecret: string
authUrl: string
}
}
const nullableEnvVars = [
@ -165,6 +170,9 @@ const nullableEnvVars = [
'MQ_REDIS_CERT',
'IMPORTER_METRICS_COLLECTOR_URL',
'INTERNAL_API_URL',
'NOTION_CLIENT_ID',
'NOTION_CLIENT_SECRET',
'NOTION_AUTH_URL',
] // Allow some vars to be null/empty
/* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */
@ -311,6 +319,11 @@ export function getEnv(): BackendEnv {
cert: parse('REDIS_CERT')?.replace(/\\n/g, '\n'), // replace \n with new line
},
}
const notion = {
clientId: parse('NOTION_CLIENT_ID'),
clientSecret: parse('NOTION_CLIENT_SECRET'),
authUrl: parse('NOTION_AUTH_URL'),
}
return {
pg,
@ -333,6 +346,7 @@ export function getEnv(): BackendEnv {
pocket,
subscription,
redis,
notion,
}
}