diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 1857611..b248646 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,8 +3,8 @@ import { authBot } from '@roleypoly/api/src/routes/auth/bot'; import { authCallback } from '@roleypoly/api/src/routes/auth/callback'; import { withAuthMode } from '@roleypoly/api/src/sessions/middleware'; import { Router } from 'itty-router'; -import { Config, Environment, parseEnvironment } from './config'; import { authBounce } from './routes/auth/bounce'; +import { Config, Environment, parseEnvironment } from './utils/config'; import { Context } from './utils/context'; import { json, notFound } from './utils/response'; diff --git a/packages/api/src/routes/auth/bounce.ts b/packages/api/src/routes/auth/bounce.ts index efb9f4f..310a2e6 100644 --- a/packages/api/src/routes/auth/bounce.ts +++ b/packages/api/src/routes/auth/bounce.ts @@ -1,5 +1,5 @@ -import { Config } from '@roleypoly/api/src/config'; import { setupStateSession } from '@roleypoly/api/src/sessions/state'; +import { Config } from '@roleypoly/api/src/utils/config'; import { Context } from '@roleypoly/api/src/utils/context'; import { getQuery } from '@roleypoly/api/src/utils/request'; import { seeOther } from '@roleypoly/api/src/utils/response'; diff --git a/packages/api/src/routes/auth/callback.spec.ts b/packages/api/src/routes/auth/callback.spec.ts index 4746ece..7219359 100644 --- a/packages/api/src/routes/auth/callback.spec.ts +++ b/packages/api/src/routes/auth/callback.spec.ts @@ -1,9 +1,9 @@ jest.mock('../../utils/discord'); jest.mock('../../sessions/create'); -import { parseEnvironment } from '../../config'; import { createSession } from '../../sessions/create'; import { setupStateSession } from '../../sessions/state'; +import { parseEnvironment } from '../../utils/config'; import { discordFetch } from '../../utils/discord'; import { getBindings, makeRequest } from '../../utils/testHelpers'; diff --git a/packages/api/src/sessions/create.spec.ts b/packages/api/src/sessions/create.spec.ts new file mode 100644 index 0000000..0a8fbbe --- /dev/null +++ b/packages/api/src/sessions/create.spec.ts @@ -0,0 +1,53 @@ +jest.mock('../utils/discord'); + +import { AuthTokenResponse } from '@roleypoly/types'; +import { parseEnvironment } from '../utils/config'; +import { getTokenGuilds, getTokenUser } from '../utils/discord'; +import { getBindings } from '../utils/testHelpers'; +import { createSession } from './create'; + +const mockGetTokenGuilds = getTokenGuilds as jest.Mock; +const mockGetTokenUser = getTokenUser as jest.Mock; + +it('creates a session from tokens', async () => { + const config = parseEnvironment(getBindings()); + + const tokens: AuthTokenResponse = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + scope: 'identify guilds', + token_type: 'Bearer', + }; + + mockGetTokenUser.mockReturnValueOnce({ + id: 'test-user-id', + username: 'test-username', + discriminator: 'test-discriminator', + avatar: 'test-avatar', + bot: false, + }); + + mockGetTokenGuilds.mockReturnValueOnce([]); + + const session = await createSession(config, tokens); + + expect(session).toEqual({ + sessionID: expect.any(String), + user: { + id: 'test-user-id', + discriminator: 'test-discriminator', + avatar: 'test-avatar', + bot: false, + username: 'test-username', + }, + guilds: [], + tokens, + }); + + expect(mockGetTokenUser).toBeCalledWith(tokens.access_token); + expect(mockGetTokenGuilds).toBeCalledWith(tokens.access_token); + + const savedSession = await config.kv.sessions.get(session?.sessionID || ''); + expect(savedSession).toEqual(session); +}); diff --git a/packages/api/src/sessions/create.ts b/packages/api/src/sessions/create.ts index 1d6fd31..4b40efd 100644 --- a/packages/api/src/sessions/create.ts +++ b/packages/api/src/sessions/create.ts @@ -1,8 +1,7 @@ -import { Config } from '@roleypoly/api/src/config'; +import { Config } from '@roleypoly/api/src/utils/config'; import { getTokenGuilds, getTokenUser } from '@roleypoly/api/src/utils/discord'; +import { getID } from '@roleypoly/api/src/utils/id'; import { AuthTokenResponse, SessionData } from '@roleypoly/types'; -import { monotonicFactory } from 'ulid-workers'; -const ulid = monotonicFactory(); export const createSession = async ( config: Config, @@ -17,7 +16,7 @@ export const createSession = async ( return null; } - const sessionID = ulid(); + const sessionID = getID(); const session: SessionData = { sessionID, diff --git a/packages/api/src/sessions/middleware.spec.ts b/packages/api/src/sessions/middleware.spec.ts new file mode 100644 index 0000000..8babad3 --- /dev/null +++ b/packages/api/src/sessions/middleware.spec.ts @@ -0,0 +1,163 @@ +import { Router } from 'itty-router'; +import { Config, parseEnvironment } from '../utils/config'; +import { Context } from '../utils/context'; +import { json } from '../utils/response'; +import { getBindings, makeSession } from '../utils/testHelpers'; +import { requireSession, withAuthMode, withSession } from './middleware'; + +const setup = (): [Config, Context] => { + const config = parseEnvironment(getBindings()); + const context: Context = { + config, + fetchContext: { + waitUntil: () => {}, + }, + authMode: { + type: 'anonymous', + }, + }; + + return [config, context]; +}; + +it('detects anonymous auth mode via middleware', async () => { + const [, context] = setup(); + const router = Router(); + router.all('*', withAuthMode).get('/', (request, context) => { + expect(context.authMode.type).toBe('anonymous'); + }); + + await router.handle(new Request('http://test.local/'), context); +}); + +it('detects bearer auth mode via middleware', async () => { + const [, context] = setup(); + + const token = 'abc123'; + const router = Router(); + router.all('*', withAuthMode).get('/', (request, context) => { + expect(context.authMode.type).toBe('bearer'); + expect(context.authMode.sessionId).toBe(token); + }); + + await router.handle( + new Request('http://test.local/', { + headers: { + authorization: `Bearer ${token}`, + }, + }), + context + ); +}); + +it('detects bot auth mode via middleware', async () => { + const [, context] = setup(); + + const token = 'abc123'; + const router = Router(); + router.all('*', withAuthMode).get('/', (request, context) => { + expect(context.authMode.type).toBe('bot'); + expect(context.authMode.identity).toBe(token); + }); + + await router.handle( + new Request('http://test.local/', { + headers: { + authorization: `Bot ${token}`, + }, + }), + context + ); +}); + +it('sets Context.session via withSession middleware', async () => { + const [config, context] = setup(); + + const session = await makeSession(config); + + const router = Router(); + router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => { + expect(context.session).toBeDefined(); + expect(context.session!.sessionID).toBe(session.sessionID); + }); + + await router.handle( + new Request('http://test.local/', { + headers: { + authorization: `Bearer ${session.sessionID}`, + }, + }), + context + ); +}); + +it('does not set Context.session when session is invalid', async () => { + const [, context] = setup(); + + const router = Router(); + router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => { + expect(context.session).not.toBeDefined(); + }); + + await router.handle( + new Request('http://test.local/', { + headers: { + authorization: `Bearer abc123`, + }, + }), + context + ); +}); + +it('errors with 401 when requireSession is coupled with invalid session', async () => { + const [, context] = setup(); + const router = Router(); + + const testFn = jest.fn(); + router + .all('*', withAuthMode, withSession, requireSession) + .get('/', (request, context: Context) => { + testFn(); + return json({}); + }); + + const response = await router.handle( + new Request('http://test.local/', { + headers: { + authorization: `Bearer abc123`, + }, + }), + context + ); + + expect(testFn).not.toHaveBeenCalled(); + expect(response.status).toBe(401); +}); + +it('passes through when requireSession is coupled with a valid session', async () => { + const [config, context] = setup(); + + const session = await makeSession(config); + const router = Router(); + + const testFn = jest.fn(); + router + .all('*', withAuthMode, withSession, requireSession) + .get('/', (request, context: Context) => { + expect(context.session).toBeDefined(); + testFn(); + return json({}); + }); + + const response = await router.handle( + new Request('http://test.local/', { + headers: { + authorization: `Bearer ${session.sessionID}`, + }, + }), + context + ); + + expect(response.status).toBe(200); + expect(testFn).toHaveBeenCalled(); +}); diff --git a/packages/api/src/sessions/middleware.ts b/packages/api/src/sessions/middleware.ts index b564b86..f1bf9c3 100644 --- a/packages/api/src/sessions/middleware.ts +++ b/packages/api/src/sessions/middleware.ts @@ -1,10 +1,25 @@ import { Context } from '@roleypoly/api/src/utils/context'; +import { unauthorized } from '@roleypoly/api/src/utils/response'; +import { SessionData } from '@roleypoly/types'; -export const withSession = (request: Request, context: Context) => {}; +export const withSession = async (request: Request, context: Context) => { + if (context.authMode.type !== 'bearer') { + return; + } + + const session = await context.config.kv.sessions.get( + context.authMode.sessionId + ); + if (!session) { + return; + } + + context.session = session; +}; export const requireSession = (request: Request, context: Context) => { - if (context.authMode.type !== 'bearer') { - throw new Error('Not authed'); + if (context.authMode.type !== 'bearer' || !context.session) { + return unauthorized(); } }; diff --git a/packages/api/src/sessions/state.ts b/packages/api/src/sessions/state.ts index 60873dc..a5eddf0 100644 --- a/packages/api/src/sessions/state.ts +++ b/packages/api/src/sessions/state.ts @@ -1,4 +1,4 @@ -import { Config } from '@roleypoly/api/src/config'; +import { Config } from '@roleypoly/api/src/utils/config'; import { getID } from '@roleypoly/api/src/utils/id'; export const setupStateSession = async (config: Config, data: T): Promise => { diff --git a/packages/api/src/config.ts b/packages/api/src/utils/config.ts similarity index 100% rename from packages/api/src/config.ts rename to packages/api/src/utils/config.ts diff --git a/packages/api/src/utils/context.ts b/packages/api/src/utils/context.ts index 08d264f..79cc573 100644 --- a/packages/api/src/utils/context.ts +++ b/packages/api/src/utils/context.ts @@ -1,4 +1,4 @@ -import { Config } from '@roleypoly/api/src/config'; +import { Config } from '@roleypoly/api/src/utils/config'; import { SessionData } from '@roleypoly/types'; export type AuthMode = diff --git a/packages/api/src/utils/id.spec.ts b/packages/api/src/utils/id.spec.ts new file mode 100644 index 0000000..cd6b5e5 --- /dev/null +++ b/packages/api/src/utils/id.spec.ts @@ -0,0 +1,9 @@ +import { dateFromID, getID } from './id'; + +it('returns an id', () => { + expect(getID()).toBeTruthy(); +}); + +it('outputs a valid millisecond decoded from id', () => { + expect(dateFromID(getID())).toBeCloseTo(Date.now(), Date.now().toString.length - 2); +}); diff --git a/packages/api/src/kv.ts b/packages/api/src/utils/kv.ts similarity index 100% rename from packages/api/src/kv.ts rename to packages/api/src/utils/kv.ts diff --git a/packages/api/src/utils/request.spec.ts b/packages/api/src/utils/request.spec.ts new file mode 100644 index 0000000..2c3bd05 --- /dev/null +++ b/packages/api/src/utils/request.spec.ts @@ -0,0 +1,45 @@ +import { formData, formDataRequest, getQuery } from './request'; + +describe('getQuery', () => { + it('splits query string into object', () => { + const query = getQuery(new Request('http://local.test/?a=1&b=2')); + + expect(query).toEqual({ + a: '1', + b: '2', + }); + }); +}); + +describe('formData & formDataRequest', () => { + it('formats object into form data', () => { + const body = formData({ + a: 1, + b: 2, + }); + + expect(body).toEqual('a=1&b=2'); + }); + + it('formats object into form data with custom headers', () => { + const body = formDataRequest( + { + a: 1, + b: 2, + }, + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + } + ); + + expect(body).toEqual({ + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'a=1&b=2', + }); + }); +}); diff --git a/packages/api/src/utils/request.ts b/packages/api/src/utils/request.ts index 917636c..7053bbc 100644 --- a/packages/api/src/utils/request.ts +++ b/packages/api/src/utils/request.ts @@ -1,9 +1,9 @@ export const getQuery = (request: Request): { [x: string]: string } => { const output: { [x: string]: string } = {}; - for (let [key, value] of new URL(request.url).searchParams.entries()) { + new URL(request.url).searchParams.forEach((value, key) => { output[key] = value; - } + }); return output; }; diff --git a/packages/api/src/utils/testHelpers.ts b/packages/api/src/utils/testHelpers.ts index 0a079da..571ab13 100644 --- a/packages/api/src/utils/testHelpers.ts +++ b/packages/api/src/utils/testHelpers.ts @@ -1,4 +1,6 @@ -import { Environment } from '@roleypoly/api/src/config'; +import { Config, Environment } from '@roleypoly/api/src/utils/config'; +import { getID } from '@roleypoly/api/src/utils/id'; +import { SessionData, UserGuildPermissions } from '@roleypoly/types'; import index from '../index'; export const makeRequest = ( @@ -25,3 +27,41 @@ export const makeRequest = ( }; export const getBindings = (): Environment => getMiniflareBindings(); + +export const makeSession = async ( + config: Config, + data?: Partial +): Promise => { + const sessionID = getID(); + + const session: SessionData = { + sessionID, + tokens: { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + scope: 'identify guilds', + token_type: 'Bearer', + }, + user: { + id: 'test-user-id', + username: 'test-username', + discriminator: 'test-discriminator', + avatar: 'test-avatar', + bot: false, + }, + guilds: [ + { + id: 'test-guild-id', + name: 'test-guild-name', + icon: 'test-guild-icon', + permissionLevel: UserGuildPermissions.User, + }, + ], + ...data, + }; + + await config.kv.sessions.put(sessionID, session, config.retention.session); + + return session; +};