diff --git a/components/Login.vue b/components/Login.vue index c36c7133..f5697492 100644 --- a/components/Login.vue +++ b/components/Login.vue @@ -12,9 +12,19 @@
diff --git a/locale/en/translations.suml b/locale/en/translations.suml index 7921250a..4ab8f40e 100644 --- a/locale/en/translations.suml +++ b/locale/en/translations.suml @@ -487,6 +487,7 @@ user: why: > Registering lets you manage your cards ({/@example=like this one}). passwordless: 'The website doesn''t store any passwords. {https://avris.it/blog/passwords-are-passé=More info.}' + instancePlaceholder: 'Instance' code: action: 'Validate' invalid: 'Invalid code.' diff --git a/locale/pl/translations.suml b/locale/pl/translations.suml index c173297d..aa9c9af9 100644 --- a/locale/pl/translations.suml +++ b/locale/pl/translations.suml @@ -1184,6 +1184,7 @@ user: why: > Założenie konta pozwala na zarządzanie swoimi wizytówkami ({/@example=takimi jak ta}). passwordless: 'Strona nie zapisuje żadnych haseł. {https://avris.it/blog/passwords-are-passé=Więcej info.}' + instancePlaceholder: 'Instancja' code: action: 'Sprawdź' invalid: 'Kod nieprawidłowy.' diff --git a/migrations/041-mastodon-oauth.sql b/migrations/041-mastodon-oauth.sql new file mode 100644 index 00000000..91588917 --- /dev/null +++ b/migrations/041-mastodon-oauth.sql @@ -0,0 +1,11 @@ +-- Up + +CREATE TABLE mastodon_oauth ( + instance TEXT NOT NULL PRIMARY KEY, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL +); + +-- Down + +DROP TABLE mastodon_oauth; diff --git a/server/index.js b/server/index.js index 270c3c8f..23e8b2ed 100644 --- a/server/index.js +++ b/server/index.js @@ -82,6 +82,7 @@ app.use(async function (req, res, next) { } }); +app.use(require('./routes/grantOverrides').default); router.use(grant.express()(require('./social').config)); app.use(require('./routes/home').default); diff --git a/server/routes/grantOverrides.js b/server/routes/grantOverrides.js new file mode 100644 index 00000000..19cb94be --- /dev/null +++ b/server/routes/grantOverrides.js @@ -0,0 +1,101 @@ +// grant doesn't care about the specifics of some services, +// so for some services we don't care about grant :)))) + +import { Router } from 'express'; +import SQL from 'sql-template-strings'; +import fetch from 'node-fetch'; +import assert from 'assert'; +import { handleErrorAsync } from "../../src/helpers"; + +const normalizeDomainName = (domain) => { + const url = new URL('https://' + domain); + assert(url.port === ''); + return url.hostname; +} + +const config = { + mastodon: { + scopes: ['read:accounts'], + redirect_uri: `${process.env.HOME_URL || 'https://pronouns.page'}/api/user/social/mastodon`, + }, +}; + +const router = Router(); + +const mastodonGetOAuthKeys = async (db, instance, dbOnly = false) => { + const existingKeys = await db.get( + SQL`SELECT client_id, client_secret FROM mastodon_oauth WHERE instance = ${instance}`); + if (existingKeys) { + return existingKeys; + } + const keys = await fetch(`https://${instance}/api/v1/apps`, { + method: 'POST', + body: new URLSearchParams({ + client_name: 'pronouns.page', + redirect_uris: config.mastodon.redirect_uri, + scopes: config.mastodon.scopes.join(' '), + website: process.env.HOME_URL, + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'pronouns.page', + }, + }).then(res => res.json()); + assert(keys.client_id && keys.client_secret && !keys.error); + db.get(SQL` + INSERT INTO mastodon_oauth (instance, client_id, client_secret) + VALUES (${instance}, ${keys.client_id}, ${keys.client_secret}) + `); + return keys; +}; +router.get('/connect/mastodon', handleErrorAsync(async (req, res) => { + assert(req.query.instance); + const instance = normalizeDomainName(req.query.instance); + const { client_id, client_secret } = await mastodonGetOAuthKeys(req.db, instance); + req.session.grant = { instance, client_id, client_secret }; + res.redirect(`https://${instance}/oauth/authorize?` + new URLSearchParams({ + client_id, + scope: config.mastodon.scopes.join(' '), + redirect_uri: config.mastodon.redirect_uri, + response_type: 'code', + })); +})); +router.get('/user/social/mastodon', handleErrorAsync(async (req, res, next) => { + if (!req.session.grant || !req.session.grant.instance || !req.query.code) { + next(); + return; + } + const { instance, client_id, client_secret } = req.session.grant; + const response = await fetch(`https://${instance}/oauth/token`, { + method: 'POST', + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id, + client_secret, + redirect_uri: config.mastodon.redirect_uri, + scope: config.mastodon.scopes.join(' '), + code: req.query.code, + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'pronouns.page', + }, + }).then(res => res.json()); + if (!response.access_token || response.error) { + next(); + return; + } + const profile = await fetch(`https://${instance}/api/v1/accounts/verify_credentials`, { + headers: { + Authorization: `Bearer ${response.access_token}`, + 'User-Agent': 'pronouns.page', + }, + }).then(res => res.json()); + response.profile = profile; + response.instance = instance; + req.session.grant.response = response; + next(); + return; +})); + +export default router; diff --git a/server/routes/user.js b/server/routes/user.js index 861e987d..b262629b 100644 --- a/server/routes/user.js +++ b/server/routes/user.js @@ -388,7 +388,9 @@ router.post('/user/:id/set-roles', handleErrorAsync(async (req, res) => { // happens on home router.get('/user/social-redirect/:provider/:locale', handleErrorAsync(async (req, res) => { req.session.socialRedirect = req.params.locale; - return res.redirect(`/api/connect/${req.params.provider}`); + return res.redirect(`/api/connect/${req.params.provider}?${new URLSearchParams({ + instance: req.query.instance || undefined, + })}`); })); // happens on home diff --git a/server/social.js b/server/social.js index 3a22555d..5b9fb5d4 100644 --- a/server/social.js +++ b/server/social.js @@ -30,6 +30,8 @@ export const config = { callback: '/api/user/social/discord', scope: ['identify', 'email'], }, + // non-grant, but things break if it's not there + mastodon: {}, } export const handlers = { @@ -73,4 +75,13 @@ export const handlers = { access_secret: r.access_secret, } }, + mastodon(r) { + const acct = `${r.profile.username}@${r.instance}`; + return { + id: acct, + name: acct, + avatar: r.profile.avatar, + access_token: r.access_token, + }; + }, }; diff --git a/src/data.js b/src/data.js index 769ce596..8e86cd59 100644 --- a/src/data.js +++ b/src/data.js @@ -7,6 +7,7 @@ export const socialProviders = { facebook: { name: 'Facebook' }, google: { name: 'Google' }, discord: { name: 'Discord' }, + mastodon: { name: 'Mastodon', instanceRequired: true }, } import pronounsRaw from '../data/pronouns/pronouns.tsv';