From 7ab17001f0dd2a1192f361086ddf7a0c1f825a16 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Tue, 15 Nov 2022 23:48:23 +0800 Subject: [PATCH] feat: basic oauth --- components/account/AccountMe.client.vue | 12 ++++++ composables/client.ts | 5 +++ composables/cookies.ts | 11 +++++ constants/index.ts | 1 + layouts/default.vue | 4 +- package.json | 3 +- pages/login.vue | 54 ------------------------- pages/login/callback.vue | 17 ++++++++ pages/login/index.vue | 32 +++++++++++++++ plugins/masto.ts | 12 +----- plugins/store.client.ts | 47 +++++++++++++++++++++ pnpm-lock.yaml | 8 ++-- scripts/registerApps.ts | 43 +++++++++++--------- server/api/[server]/oauth.ts | 21 ++++++---- server/shared.ts | 18 ++++----- types/index.ts | 17 ++++++++ 16 files changed, 199 insertions(+), 106 deletions(-) create mode 100644 components/account/AccountMe.client.vue create mode 100644 composables/cookies.ts delete mode 100644 pages/login.vue create mode 100644 pages/login/callback.vue create mode 100644 pages/login/index.vue create mode 100644 plugins/store.client.ts create mode 100644 types/index.ts diff --git a/components/account/AccountMe.client.vue b/components/account/AccountMe.client.vue new file mode 100644 index 00000000..05979ead --- /dev/null +++ b/components/account/AccountMe.client.vue @@ -0,0 +1,12 @@ + + + diff --git a/composables/client.ts b/composables/client.ts index c1559d51..5568891b 100644 --- a/composables/client.ts +++ b/composables/client.ts @@ -1,5 +1,10 @@ import type { MastoClient } from 'masto' +import type { AppStore } from '~~/plugins/store.client' export function useMasto() { return inject('masto') as Promise } + +export function useAppStore() { + return inject('app-store') as AppStore +} diff --git a/composables/cookies.ts b/composables/cookies.ts new file mode 100644 index 00000000..ae4ee63d --- /dev/null +++ b/composables/cookies.ts @@ -0,0 +1,11 @@ +import { DEFAULT_SERVER } from '~/constants' + +export function useAppCookies() { + const server = useCookie('nuxtodon-server', { default: () => DEFAULT_SERVER }) + const token = useCookie('nuxtodon-token') + + return { + server, + token, + } +} diff --git a/constants/index.ts b/constants/index.ts index 0090e756..d17d7f9a 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -4,3 +4,4 @@ export const HOST_DOMAIN = process.dev ? 'http://localhost:3000' : 'https://nuxtodon.netlify.app' +export const DEFAULT_SERVER = 'mas.to' diff --git a/layouts/default.vue b/layouts/default.vue index f155eec2..f361970a 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -10,7 +10,9 @@
- + + +
diff --git a/package.json b/package.json index 948ffd98..4caa26c7 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,13 @@ "dev": "nuxi dev", "start": "node .output/server/index.mjs", "lint": "eslint .", + "register-apps": "esno ./scripts/registerApps.ts", "postinstall": "nuxi prepare", "generate": "nuxi generate" }, "devDependencies": { "@antfu/eslint-config": "^0.30.1", - "@iconify-json/carbon": "^1.1.9", + "@iconify-json/carbon": "^1.1.10", "@iconify-json/logos": "^1.1.18", "@iconify-json/ri": "^1.1.3", "@iconify-json/twemoji": "^1.1.5", diff --git a/pages/login.vue b/pages/login.vue deleted file mode 100644 index 90b3189b..00000000 --- a/pages/login.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - diff --git a/pages/login/callback.vue b/pages/login/callback.vue new file mode 100644 index 00000000..a63dcfa6 --- /dev/null +++ b/pages/login/callback.vue @@ -0,0 +1,17 @@ + + + diff --git a/pages/login/index.vue b/pages/login/index.vue new file mode 100644 index 00000000..91e67ef5 --- /dev/null +++ b/pages/login/index.vue @@ -0,0 +1,32 @@ + + + diff --git a/plugins/masto.ts b/plugins/masto.ts index 7f4c7616..820dacfb 100644 --- a/plugins/masto.ts +++ b/plugins/masto.ts @@ -1,19 +1,11 @@ import { login } from 'masto' -export const DEFAULT_SERVER = 'mas.to' - export default defineNuxtPlugin((nuxt) => { - const server = useCookie('nuxtodon-server') - const token = useCookie('nuxtodon-token') + const { server, token } = useAppCookies() const masto = login({ - url: `https://${server.value || DEFAULT_SERVER}`, + url: `https://${server.value}`, accessToken: token.value, }) nuxt.vueApp.provide('masto', masto) - - // Reload the page when the token changes - watch(token, () => { - location.reload() - }) }) diff --git a/plugins/store.client.ts b/plugins/store.client.ts new file mode 100644 index 00000000..aaba9e4f --- /dev/null +++ b/plugins/store.client.ts @@ -0,0 +1,47 @@ +import { login as loginMasto } from 'masto' +import type { UserLogin } from '~/types' + +function createStore() { + const { server, token } = useAppCookies() + const accounts = useLocalStorage('nuxtodon-accounts', [], { deep: true }) + const currentIndex = useLocalStorage('nuxtodon-current-user', -1) + const currentUser = computed(() => accounts.value[currentIndex.value]) + + async function login(user: UserLogin) { + const existing = accounts.value.findIndex(u => u.server === user.server && u.token === user.token) + if (existing !== -1) { + if (currentIndex.value === existing) + return null + currentIndex.value = existing + server.value = user.server + token.value = user.token + return true + } + + const masto = await loginMasto({ + url: `https://${user.server}`, + accessToken: user.token, + }) + const me = await masto.accounts.verifyCredentials() + user.account = me + + accounts.value.push(user) + currentIndex.value = accounts.value.length + server.value = user.server + token.value = user.token + + return true + } + + return { + currentUser, + accounts, + login, + } +} + +export type AppStore = ReturnType + +export default defineNuxtPlugin((nuxt) => { + nuxt.vueApp.provide('app-store', createStore()) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15577bc0..9ba7587d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2,7 +2,7 @@ lockfileVersion: 5.4 specifiers: '@antfu/eslint-config': ^0.30.1 - '@iconify-json/carbon': ^1.1.9 + '@iconify-json/carbon': ^1.1.10 '@iconify-json/logos': ^1.1.18 '@iconify-json/ri': ^1.1.3 '@iconify-json/twemoji': ^1.1.5 @@ -26,7 +26,7 @@ specifiers: devDependencies: '@antfu/eslint-config': 0.30.1_rmayb2veg2btbq6mbmnyivgasy - '@iconify-json/carbon': 1.1.9 + '@iconify-json/carbon': 1.1.10 '@iconify-json/logos': 1.1.18 '@iconify-json/ri': 1.1.3 '@iconify-json/twemoji': 1.1.5 @@ -632,8 +632,8 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true - /@iconify-json/carbon/1.1.9: - resolution: {integrity: sha512-O3geRhhnE9dDDC4oT6qwBs7sdc37R9UqftiG7BP8YVDw8OcXv8i95J0ZEkIfdOXFj1Wb6kC/Uu/6VTlAqotVXg==} + /@iconify-json/carbon/1.1.10: + resolution: {integrity: sha512-k3/28wk+2CklUPdKBXWhPHQxquvLGiPYk1s6UWdQYR3YtneHRMuCp+0zc9yNjHYBzoSylMIECO0UPH+SdccguA==} dependencies: '@iconify/types': 2.0.0 dev: true diff --git a/scripts/registerApps.ts b/scripts/registerApps.ts index 9aaaf065..2d29045a 100644 --- a/scripts/registerApps.ts +++ b/scripts/registerApps.ts @@ -1,7 +1,7 @@ import fs from 'fs-extra' -import type { Client } from 'masto' import { $fetch } from 'ohmyfetch' -import { APP_NAME } from '~~/constants' +import { APP_NAME } from '~/constants' +import type { AppInfo } from '~/types' const KNOWN_SERVERS = [ 'mastodon.social', @@ -9,33 +9,38 @@ const KNOWN_SERVERS = [ 'fosstodon.org', ] +const KNOWN_DOMAINS = [ + 'http://localhost:3000', + 'https://nuxtodon.netlify.app', +] + const filename = 'public/registered-apps.json' -let registeredApps: Record = {} +let registeredApps: Record = {} if (fs.existsSync(filename)) registeredApps = await fs.readJSON(filename) for (const server of KNOWN_SERVERS) { - if (registeredApps[server]) - continue + const redirect_uris = [ + 'urn:ietf:wg:oauth:2.0:oob', + ...KNOWN_DOMAINS.map(d => `${d}/api/${server}/oauth`), + ].join('\n') - const app = await $fetch(`https://${server}/api/v1/apps`, { - method: 'POST', - body: { - client_name: APP_NAME, - redirect_uris: [ - 'urn:ietf:wg:oauth:2.0:oob', - 'http://localhost:3000/*', - 'https://nuxtodon.netlify.app/*', - ].join('\n'), - scopes: 'read write follow push', - }, - }) + if (!registeredApps[server] || registeredApps[server].redirect_uri !== redirect_uris) { + const app = await $fetch(`https://${server}/api/v1/apps`, { + method: 'POST', + body: { + client_name: APP_NAME, + redirect_uris, + scopes: 'read write follow push', + }, + }) - registeredApps[server] = app + registeredApps[server] = app - console.log(`Registered app for ${server}`) + console.log(`Registered app for ${server}`) + } } await fs.writeJSON(filename, registeredApps, { spaces: 2, EOL: '\n' }) diff --git a/server/api/[server]/oauth.ts b/server/api/[server]/oauth.ts index be3b876f..fcb42f88 100644 --- a/server/api/[server]/oauth.ts +++ b/server/api/[server]/oauth.ts @@ -1,29 +1,36 @@ import { getQuery } from 'ufo' +import { stringifyQuery } from 'vue-router' import { getApp } from '~/server/shared' +import { HOST_DOMAIN } from '~/constants' -export default defineEventHandler(async (event) => { - const server = event.context.params.server +export default defineEventHandler(async ({ context, req, res }) => { + const server = context.params.server const app = await getApp(server) if (!app) { - event.res.statusCode = 400 + res.statusCode = 400 return `App not registered for server: ${server}` } - const query = getQuery(event.req.url!) + const query = getQuery(req.url!) const code = query.code - const res = await $fetch(`https://${server}/oauth/token`, { + const result: any = await $fetch(`https://${server}/oauth/token`, { method: 'POST', body: { client_id: app.client_id, client_secret: app.client_secret, - redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + redirect_uri: `${HOST_DOMAIN}/api/${server}/oauth`, grant_type: 'authorization_code', code, scope: 'read write follow push', }, }) - console.log({ res }) + res.writeHead(302, { + Location: `${HOST_DOMAIN}/login/callback?${stringifyQuery({ server, token: result.access_token })}`, + }) + res.end() + + return result }) diff --git a/server/shared.ts b/server/shared.ts index 78ce395a..b848cbb1 100644 --- a/server/shared.ts +++ b/server/shared.ts @@ -1,19 +1,17 @@ import { $fetch } from 'ohmyfetch' - -export interface AppInfo { - id: string - name: string - website: string | null - redirect_uri: string - client_id: string - client_secret: string - vapid_key: string -} +import type { AppInfo } from '~/types' export const registeredApps: Record = {} const promise = $fetch(process.env.APPS_JSON_URL || 'http://localhost:3000/registered-apps.json') .then(r => Object.assign(registeredApps, r)) + .catch((e) => { + if (process.dev) + console.error('Failed to fetch registered apps,\nyou may need to run `nr register-apps` first') + else + console.error('Failed to fetch registered apps') + console.error(e) + }) export async function getApp(server: string) { await promise diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 00000000..1a2dc8d2 --- /dev/null +++ b/types/index.ts @@ -0,0 +1,17 @@ +import type { AccountCredentials } from 'masto' + +export interface AppInfo { + id: string + name: string + website: string | null + redirect_uri: string + client_id: string + client_secret: string + vapid_key: string +} + +export interface UserLogin { + server: string + token: string + account?: AccountCredentials +}