Create /api/oauth/authorize route

This commit is contained in:
Dominik Korsa 2021-01-16 23:14:51 +01:00
commit 2e4357f7f8
No known key found for this signature in database
GPG key ID: 546F986F71A6FE6E
23 changed files with 3861 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
poc

11
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/../../../:\wulkanowy-bridge\.idea/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
jsLinters
discord.xml

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="file://$PROJECT_DIR$" libraries="{Node.js Core}" />
</component>
</project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

1
README.md Normal file
View file

@ -0,0 +1 @@
wulkanowy-bridge

9
backend/.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

34
backend/.eslintrc.json Normal file
View file

@ -0,0 +1,34 @@
{
"env": {
"es6": true,
"node": true
},
"extends": [
"airbnb-typescript/base",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/explicit-member-accessibility": ["error"],
"import/order": ["error", {
"alphabetize": {
"order": "asc"
}
}],
"import/prefer-default-export": "warn",
"no-console": "off",
"max-len": ["off"],
"import/first": ["off"],
"max-classes-per-file": ["off"]
}
}

3
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
.env
dist

3409
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
backend/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "@wulkanowy/bridge-backend",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src -c .eslintrc.json --ext .ts",
"lint:fix": "eslint src -c .eslintrc.json --ext .ts --fix",
"start": "ts-node .",
"dev": "ts-node-dev ."
},
"author": "Dominik Korsa <dominik.korsa@gmail.com>",
"license": "MIT",
"private": true,
"dependencies": {
"@types/express": "^4.17.10",
"@types/lodash": "^4.14.167",
"@types/node": "^14.14.21",
"dotenv": "^8.2.0",
"fastify": "^3.10.1",
"fastify-cookie": "^5.1.0",
"fastify-sensible": "^3.1.0",
"fastify-session": "^5.2.1",
"lodash": "^4.17.20",
"mongodb": "^3.6.3",
"nanoid": "^3.1.20",
"pino-pretty": "^4.3.0",
"reflect-metadata": "^0.1.13",
"ts-node": "^9.1.1",
"typeorm": "^0.2.30",
"typescript": "^4.1.3"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.13.0",
"@typescript-eslint/parser": "^4.13.0",
"eslint": "^7.17.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-typescript": "^12.0.0",
"eslint-plugin-import": "^2.22.1",
"ts-node-dev": "^1.1.1"
}
}

6
backend/src/constants.ts Normal file
View file

@ -0,0 +1,6 @@
export const scopes = [
'timetable',
'grades',
'notes',
'achievements',
];

View file

@ -0,0 +1,25 @@
import { Connection, createConnection, Repository } from 'typeorm';
import Application from './entities/application';
class Database {
private connection!: Connection;
public applicationRepo!: Repository<Application>;
public async connect(): Promise<void> {
this.connection = await createConnection({
type: 'mongodb',
host: 'localhost',
port: 27017,
database: 'wulkanowy-bridge-local',
entities: [
Application,
],
useUnifiedTopology: true,
logging: false,
});
this.applicationRepo = this.connection.getRepository(Application);
}
}
export default new Database();

View file

@ -0,0 +1,32 @@
import { nanoid } from 'nanoid';
import {
Column, Entity, ObjectID, ObjectIdColumn,
} from 'typeorm';
@Entity()
export default class Application {
@ObjectIdColumn()
public _id!: ObjectID;
@Column()
public clientId!: string;
@Column()
public name!: string;
@Column()
public iconUrl!: string | null;
@Column()
public verified!: boolean;
@Column()
public redirectUris!: string[];
@Column()
public public!: string[];
public static generateClientId(): string {
return nanoid(12);
}
}

7
backend/src/errors.ts Normal file
View file

@ -0,0 +1,7 @@
export class ParamError extends Error {
public name = 'ParamError';
}
export class ScopeError extends Error {
public name = 'ScopeError';
}

44
backend/src/index.ts Normal file
View file

@ -0,0 +1,44 @@
import 'reflect-metadata';
import dotenv from 'dotenv';
dotenv.config();
import Fastify from 'fastify';
import FastifyCookie from 'fastify-cookie';
import FastifySensible from 'fastify-sensible';
import FastifySession from 'fastify-session';
import database from './database/database';
import registerOAuth from './routes/oauth2';
import { parseIntStrict, requireEnv } from './utils';
const server = Fastify({
logger: {
level: 'info',
prettyPrint: true,
},
});
async function start() {
await server.register(FastifySensible);
await server.register(FastifyCookie);
await server.register(FastifySession, {
secret: requireEnv('SESSION_SECRET'),
cookie: {
secure: false, // TODO: Remove this line or add development env variable
},
});
await server.register(registerOAuth, { prefix: '/api/oauth', logLevel: 'info' });
await database.connect();
server.log.info('Connected to database');
const port = parseIntStrict(requireEnv('PORT'));
await server.listen(port);
server.log.info(`Listening on port ${port}`);
}
start()
.catch((error) => {
server.log.error(error);
process.exit(1);
});

View file

@ -0,0 +1,92 @@
import _ from 'lodash';
import { nanoid } from 'nanoid';
import { scopes } from '../../constants';
import database from '../../database/database';
import { ParamError, ScopeError } from '../../errors';
import { MyFastifyInstance } from '../../types';
import {
getSessionData, isObject, parseScopeParam, validateOptionalParam, validateParam,
} from '../../utils';
export default function registerAuthorize(server: MyFastifyInstance): void {
server.post('/authorize', async (
request,
reply,
) => {
console.log(request.query);
if (!isObject(request.query)) {
server.log.warn('Request query is not an object');
throw server.httpErrors.badRequest();
}
server.log.info(JSON.stringify(request.body));
try {
validateParam('client_id', request.query.client_id);
validateParam('redirect_uri', request.query.redirect_uri);
} catch (error) {
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
server.log.error(error);
throw server.httpErrors.internalServerError();
}
const application = await database.applicationRepo.findOne({
where: {
clientId: request.query.client_id,
},
});
if (application === undefined) throw server.httpErrors.badRequest('Unknown application');
if (!application.redirectUris.includes(request.query.redirect_uri)) throw server.httpErrors.badRequest('Redirect URI not registered');
try {
validateParam('response_type', request.query.response_type);
if (request.query.response_type === 'code') {
const requestedScopes = _.uniq(parseScopeParam('scope', request.query.scope));
requestedScopes.forEach((scope) => {
if (!scopes.includes(scope)) {
throw new ScopeError(`Unknown scope ${scope}`);
}
});
validateOptionalParam('state', request.query.state);
validateOptionalParam('code_challenge', request.query.code_challenge);
validateOptionalParam('code_challenge_method', request.query.code_challenge_method);
const codeChallengeMethod = request.query.code_challenge_method ?? 'plain';
if (codeChallengeMethod !== 'plain' && codeChallengeMethod !== 'S256') {
await reply.redirect(`${request.query.redirect_uri}?error=invalid_request&error_description=${encodeURIComponent('code_challenge_method should be either plain or S256')}`);
return;
}
const promptId = nanoid(12);
const sessionData = getSessionData(request.session);
sessionData.prompts.set(promptId, {
clientId: request.query.client_id,
redirectUri: request.query.redirect_uri,
scopes,
state: request.query.state,
codeChallenge: request.query.code_challenge === undefined ? undefined : {
method: codeChallengeMethod,
value: request.query.code_challenge,
},
});
await reply.redirect(`/authenticate-prompt?prompt_id=${promptId}`);
return;
}
await reply.redirect(`${request.query.redirect_uri}?error=unsupported_response_type`);
return;
} catch (error) {
if (error instanceof ParamError) {
await reply.redirect(`${request.query.redirect_uri}?error=invalid_request&error_description=${encodeURIComponent(error.message)}`);
return;
}
if (error instanceof ScopeError) {
await reply.redirect(`${request.query.redirect_uri}?error=invalid_scope&error_description=${encodeURIComponent(error.message)}`);
return;
}
server.log.error(error);
await reply.redirect(`${request.query.redirect_uri}?error=server_error`);
}
});
}

View file

@ -0,0 +1,9 @@
import {
MyFastifyInstance,
} from '../../types';
import registerAuthorize from './authorize';
// eslint-disable-next-line @typescript-eslint/require-await
export default async function registerOAuth(server: MyFastifyInstance): Promise<void> {
registerAuthorize(server);
}

28
backend/src/types.ts Normal file
View file

@ -0,0 +1,28 @@
import {
FastifyInstance, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault,
} from 'fastify';
export interface Prompt {
clientId: string;
redirectUri: string;
scopes: string[],
state?: string;
codeChallenge?: {
value: string;
method: 'plain' | 'S256';
};
}
export interface SessionData {
prompts: Map<string, Prompt>
}
export interface Session {
sessionId: string;
encryptedSessionId: string;
touch(): void;
regenerate(): void;
data?: SessionData;
}
export type MyFastifyInstance = FastifyInstance<RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>;

55
backend/src/utils.ts Normal file
View file

@ -0,0 +1,55 @@
import _ from 'lodash';
import { ParamError } from './errors';
import { Prompt, Session, SessionData } from './types';
export function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined) throw new Error(`Environment variable ${name} not set`);
return value;
}
export function parseIntStrict(value: string, radix = 10): number {
const number = parseInt(value, radix);
if (_.isNaN(number)) throw new Error(`Cannot parse ${value} to int`);
return number;
}
export function parseFloatStrict(value: string): number {
const number = parseFloat(value);
if (_.isNaN(number)) throw new Error(`Cannot parse ${value} to float`);
return number;
}
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function validateParam(key: string, value: unknown): asserts value is string {
if (value === undefined) throw new ParamError(`${key} param is missing`);
if (typeof value !== 'string') throw new ParamError(`${key} param should be a string`);
}
export function validateOptionalParam(key: string, value: unknown): asserts value is string | undefined {
if (value === undefined) return;
if (typeof value !== 'string') throw new ParamError(`${key} param should be a string`);
}
export function parseScopeParam(key: string, value: unknown): string[] {
if (value === undefined) throw new ParamError(`${key} param is missing`);
if (typeof value === 'string') {
if (value === '') return [];
return value.split('+').map((scope) => scope.trim());
}
if (_.isArray(value)) return value.flatMap((e) => parseScopeParam(key, e));
throw new ParamError(`${key} param should be a string or an array of strings`);
}
export function getSessionData(session: Session): SessionData {
if (session.data === undefined) {
// eslint-disable-next-line no-param-reassign
session.data = {
prompts: new Map<string, Prompt>(),
};
}
return session.data;
}

18
backend/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"lib": ["ES2020"],
"types": [
"node"
]
}
}