Create /api/oauth/authorize route
This commit is contained in:
commit
2e4357f7f8
23 changed files with 3861 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
poc
|
11
.idea/.gitignore
vendored
Normal file
11
.idea/.gitignore
vendored
Normal 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
|
6
.idea/inspectionProfiles/Project_Default.xml
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
Normal 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>
|
6
.idea/jsLibraryMappings.xml
Normal file
6
.idea/jsLibraryMappings.xml
Normal 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>
|
6
.idea/jsLinters/eslint.xml
Normal file
6
.idea/jsLinters/eslint.xml
Normal 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
6
.idea/vcs.xml
Normal 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>
|
9
.idea/wulkanowy-bridge.iml
Normal file
9
.idea/wulkanowy-bridge.iml
Normal 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
1
README.md
Normal file
|
@ -0,0 +1 @@
|
|||
wulkanowy-bridge
|
9
backend/.editorconfig
Normal file
9
backend/.editorconfig
Normal 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
34
backend/.eslintrc.json
Normal 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
3
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.env
|
||||
dist
|
3409
backend/package-lock.json
generated
Normal file
3409
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
backend/package.json
Normal file
43
backend/package.json
Normal 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
6
backend/src/constants.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const scopes = [
|
||||
'timetable',
|
||||
'grades',
|
||||
'notes',
|
||||
'achievements',
|
||||
];
|
25
backend/src/database/database.ts
Normal file
25
backend/src/database/database.ts
Normal 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();
|
32
backend/src/database/entities/application.ts
Normal file
32
backend/src/database/entities/application.ts
Normal 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
7
backend/src/errors.ts
Normal 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
44
backend/src/index.ts
Normal 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);
|
||||
});
|
92
backend/src/routes/oauth2/authorize.ts
Normal file
92
backend/src/routes/oauth2/authorize.ts
Normal 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`);
|
||||
}
|
||||
});
|
||||
}
|
9
backend/src/routes/oauth2/index.ts
Normal file
9
backend/src/routes/oauth2/index.ts
Normal 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
28
backend/src/types.ts
Normal 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
55
backend/src/utils.ts
Normal 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
18
backend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue