diff --git a/api/auth.js b/api/auth.js index 984f76c..a4b788e 100644 --- a/api/auth.js +++ b/api/auth.js @@ -24,7 +24,7 @@ export default (R: Router, $: AppContext) => { ctx.session.expiresAt = new Date() + ctx.expires_in } - const user = await $.discord.getUser(ctx.session.accessToken) + const user = await $.discord.getUserFromToken(ctx.session.accessToken) ctx.session.userId = user.id ctx.session.avatarHash = user.avatar @@ -44,7 +44,7 @@ export default (R: Router, $: AppContext) => { return } - const user = await $.discord.getUser(accessToken) + const user = await $.discord.getUserFromToken(accessToken) ctx.session.userId = user.id ctx.session.avatarHash = user.avatar @@ -57,6 +57,11 @@ export default (R: Router, $: AppContext) => { }) R.get('/api/auth/redirect', async (ctx: Context) => { + // check if already authed + if (await $.auth.isLoggedIn(ctx, { refresh: true })) { + return ctx.redirect('/') + } + const url = $.discord.getAuthUrl(ksuid.randomSync().string) if (ctx.query.url === '✔️') { ctx.body = { url } @@ -66,10 +71,52 @@ export default (R: Router, $: AppContext) => { ctx.redirect(url) }) + R.get('/api/oauth/callback', async (ctx: Context) => { + if (await $.auth.isLoggedIn(ctx)) { + return ctx.redirect('/') + } + + const { code, state } = ctx.query + if (code == null) { + ctx.status = 400 + return + } + + if (state != null) { + const ksState = ksuid.parse(state) + const twoMinAgo = new Date() - 1000 * 60 * 2 + if (ksState.date < twoMinAgo) { + ctx.status = 400 + return + } + } + + try { + const tokens = await $.discord.getAuthToken(code) + const user = await $.discord.getUserFromToken(tokens.access_token) + $.auth.injectSessionFromOAuth(ctx, tokens, user.id) + return ctx.redirect('/') + } catch (e) { + log.error('token and auth fetch failure', e) + ctx.status = 400 + } + }) + R.post('/api/auth/logout', async (ctx: Context) => { ctx.session = null }) + R.get('/api/auth/logout', async (ctx: Context) => { + if (await $.auth.isLoggedIn(ctx)) { + if (ctx.session.authType === 'oauth') { + await $.discord.revokeOAuth(ctx.session) + } + } + + ctx.session = null + return ctx.redirect('/') + }) + R.get('/api/oauth/bot', async (ctx: Context) => { const url = $.discord.getBotJoinUrl() if (ctx.query.url === '✔️') { @@ -89,16 +136,11 @@ export default (R: Router, $: AppContext) => { const chall = await $.auth.fetchDMChallenge({ magic: challenge }) if (chall == null) { log.warn('bad magic', challenge) - ctx.status = 404 - return - } - - ctx.session = { - userId: chall.userId, - authType: 'dm', - expiresAt: Date.now() + 1000 * 60 * 60 * 24 + return ctx.redirect('/auth/expired') } + $.auth.injectSessionFromChallenge(ctx, chall) + await $.auth.deleteDMChallenge(chall) log.info('logged in via magic', chall) return ctx.redirect('/') diff --git a/rpc/auth.js b/rpc/auth.js new file mode 100644 index 0000000..0814019 --- /dev/null +++ b/rpc/auth.js @@ -0,0 +1,16 @@ +// @flow +import { type AppContext } from '../Roleypoly' +import { type Context } from 'koa' + +export default ($: AppContext) => ({ + async checkAuthChallenge (ctx: Context, text: string): Promise { + const chall = await $.auth.fetchDMChallenge({ human: text }) + if (chall == null) { + return false + } + + $.auth.injectSessionFromChallenge(ctx, chall) + $.auth.deleteDMChallenge(chall) + return true + } +}) diff --git a/services/auth.js b/services/auth.js index 84d23a7..6983a4c 100644 --- a/services/auth.js +++ b/services/auth.js @@ -3,6 +3,8 @@ import Service from './Service' import nanoid from 'nanoid' import moniker from 'moniker' import type { AppContext } from '../Roleypoly' +import type { Context } from 'koa' +// import type { UserPartial } from './discord' // import type { Models } from '../models' export type DMChallenge = { @@ -12,6 +14,12 @@ export type DMChallenge = { issuedAt: Date } +export type AuthTokens = { + access_token: string, + refresh_token: string, + expires_in: string +} + export default class AuthService extends Service { M: { AuthChallenge: any } monikerGen = moniker.generator([ moniker.adjective, moniker.adjective, moniker.noun ], { glue: ' ' }) @@ -20,6 +28,26 @@ export default class AuthService extends Service { this.M = ctx.M } + async isLoggedIn (ctx: Context, { refresh = false }: { refresh: boolean } = {}) { + const { userId, expiresAt, authType } = ctx.session + if (userId == null) { + return false + } + + if (expiresAt < Date.now()) { + if (refresh && authType === 'oauth') { + const tokens = await this.ctx.discord.refreshOAuth(ctx.session) + this.injectSessionFromOAuth(ctx, tokens, userId) + return true + } + + ctx.session = null // reset session as well + return false + } + + return true + } + async createDMChallenge (userId: string): Promise { const out: DMChallenge = { userId, @@ -48,4 +76,27 @@ export default class AuthService extends Service { return challenge } + + deleteDMChallenge (input: DMChallenge) { + return this.M.AuthChallenge.destroy({ where: { magic: input.magic } }) + } + + injectSessionFromChallenge (ctx: Context, chall: DMChallenge) { + ctx.session = { + userId: chall.userId, + authType: 'dm', + expiresAt: Date.now() + 1000 * 60 * 60 * 24 + } + } + + injectSessionFromOAuth (ctx: Context, tokens: AuthTokens, userId: string) { + const { expires_in: expiresIn, access_token: accessToken, refresh_token: refreshToken } = tokens + ctx.session = { + userId, + authType: 'oauth', + expiresAt: Date.now() + expiresIn, + accessToken, + refreshToken + } + } } diff --git a/services/discord.js b/services/discord.js index 9d19ef4..f401f0b 100644 --- a/services/discord.js +++ b/services/discord.js @@ -11,6 +11,7 @@ import { type Collection, Client } from 'discord.js' +import type { AuthTokens } from './auth' export type UserPartial = { id: string, @@ -46,6 +47,7 @@ class DiscordService extends Service { super(ctx) this.appUrl = ctx.config.appUrl + this.oauthCallback = `${this.appUrl}/api/oauth/callback` this.botCallback = `${this.appUrl}/api/oauth/bot/callback` this.rootUsers = new Set((process.env.ROOT_USERS || '').split(',')) @@ -132,7 +134,7 @@ class DiscordService extends Service { } // oauth step 2 flow, grab the auth token via code - async getAuthToken (code: string) { + async getAuthToken (code: string): Promise { const url = 'https://discordapp.com/api/oauth2/token' try { const rsp = @@ -154,7 +156,7 @@ class DiscordService extends Service { } } - async getUser (authToken?: string): Promise { + async getUserFromToken (authToken?: string): Promise { const url = 'https://discordapp.com/api/v6/users/@me' try { if (authToken == null || authToken === '') { @@ -186,6 +188,50 @@ class DiscordService extends Service { } } + async refreshOAuth ({ refreshToken }: { refreshToken: string }): Promise { + const url = 'https://discordapp.com/api/oauth2/token' + try { + const rsp = + await superagent + .post(url) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + redirect_uri: this.oauthCallback + }) + + return rsp.body + } catch (e) { + this.log.error('refreshOAuth failed', e) + throw e + } + } + + async revokeOAuth ({ accessToken }: { accessToken: string }) { + const url = 'https://discordapp.com/api/oauth2/token/revoke' + try { + const rsp = + await superagent + .post(url) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'access_token', + token: accessToken, + redirect_uri: this.oauthCallback + }) + + return rsp.body + } catch (e) { + this.log.error('revokeOAuth failed', e) + throw e + } + } + // on sign out, we revoke the token we had. // async revokeAuthToken (code, state) { // const url = 'https://discordapp.com/api/oauth2/revoke'