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