work on login flow
This commit is contained in:
63
src/http/content-security-policy.ts
Normal file
63
src/http/content-security-policy.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { HttpConfig } from './server';
|
||||
import * as sch from '../utilities/json-schema';
|
||||
import { FastifyReply, FastifyInstance, FastifyRequest, RouteShorthandOptions } from 'fastify';
|
||||
|
||||
export const default_policy = `default-src 'self'`;
|
||||
|
||||
export const csp_report_schema = sch.obj({
|
||||
'csp-report': sch.obj({
|
||||
'document-uri': sch.str(),
|
||||
'referrer': sch.str(),
|
||||
'blocked-uri': sch.str(),
|
||||
'violated-directive': sch.str(),
|
||||
'original-policy': sch.str(),
|
||||
'disposition': sch.str(),
|
||||
'effective-directive': sch.str(),
|
||||
'script-sample': sch.str(),
|
||||
'status-code': sch.int(),
|
||||
}, { additionalProperties: true })
|
||||
});
|
||||
|
||||
export interface CSPReport {
|
||||
'csp-report': {
|
||||
'document-uri': string;
|
||||
'referrer': string;
|
||||
'blocked-uri': string;
|
||||
'violated-directive': string;
|
||||
'original-policy': string;
|
||||
'disposition': string;
|
||||
'effective-directive': string;
|
||||
'script-sample': string;
|
||||
'status-code': number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Dependencies {
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
export function register_csp_report_endpoint(http_server: FastifyInstance, conf: HttpConfig, { logger }: Dependencies) {
|
||||
const opts: RouteShorthandOptions = {
|
||||
schema: {
|
||||
response: {
|
||||
204: { },
|
||||
},
|
||||
body: csp_report_schema
|
||||
}
|
||||
};
|
||||
|
||||
type Req = FastifyRequest<{
|
||||
Body: CSPReport;
|
||||
}>;
|
||||
|
||||
http_server.post('/.csp-report', opts, async (req: Req, res) => {
|
||||
logger.warn(req.body['csp-report'], 'received content security policy report');
|
||||
res.status(204);
|
||||
});
|
||||
}
|
||||
|
||||
export function csp_headers(res: FastifyReply, server_url: string, policy: string = default_policy) {
|
||||
res.header('Content-Security-Policy', `${policy}; report-uri ${server_url}/.csp-report`);
|
||||
}
|
63
src/http/parse-request-content.ts
Normal file
63
src/http/parse-request-content.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { XMLParser, XMLValidator, X2jOptions } from 'fast-xml-parser';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
export function json_content_parser(http_server: FastifyInstance, media_types: string[]) {
|
||||
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, http_server.getDefaultJsonParser('ignore', 'ignore'));
|
||||
}
|
||||
|
||||
export function yaml_content_parser(http_server: FastifyInstance, media_types: string[]) {
|
||||
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, yaml_content_processor);
|
||||
}
|
||||
|
||||
export function xml_content_parser(http_server: FastifyInstance, media_types: string[]) {
|
||||
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, xml_content_processor);
|
||||
}
|
||||
|
||||
export function text_content_parser(http_server: FastifyInstance, media_types: string[]) {
|
||||
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, noop_content_processor);
|
||||
}
|
||||
|
||||
export function binary_content_parser(http_server: FastifyInstance, media_types: string[]) {
|
||||
http_server.addContentTypeParser(media_types, { parseAs: 'buffer' }, noop_content_processor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function noop_content_processor(req: FastifyRequest, payload: string | Buffer, done: (err: Error | null, body?: any) => void) {
|
||||
done(null, payload);
|
||||
}
|
||||
|
||||
function xml_content_processor(req: FastifyRequest, payload: string, done: (err: Error | null, body?: any) => void) {
|
||||
const opts: Partial<X2jOptions> = { };
|
||||
const xmlParser = new XMLParser(opts);
|
||||
const result = XMLValidator.validate(payload, opts);
|
||||
|
||||
if (typeof result === 'object' && result.err) {
|
||||
done(new RequestContentParseError(result.err.msg));
|
||||
return;
|
||||
}
|
||||
|
||||
done(null, xmlParser.parse(payload));
|
||||
}
|
||||
|
||||
function yaml_content_processor(req: FastifyRequest, payload: string, done: (err: Error | null, body?: any) => void) {
|
||||
let result: unknown;
|
||||
|
||||
try {
|
||||
result = yaml.parse(payload);
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
const message = error?.name === 'YAMLParseError' ? error.message : 'Failed to parse request body';
|
||||
done(new RequestContentParseError(message));
|
||||
return;
|
||||
}
|
||||
|
||||
done(null, result);
|
||||
}
|
||||
|
||||
export class RequestContentParseError extends Error {
|
||||
public readonly name = 'RequestContentParseError';
|
||||
}
|
52
src/http/redirects.ts
Normal file
52
src/http/redirects.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
import { FastifyReply } from 'fastify';
|
||||
|
||||
export function redirect_301_moved_permanently(res: FastifyReply, location: string, message?: string) {
|
||||
res.type('text/html; charset=utf-8');
|
||||
res.header('location', location);
|
||||
res.header('content-language', 'en-us');
|
||||
res.status(301);
|
||||
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
|
||||
}
|
||||
|
||||
export function redirect_302_found(res: FastifyReply, location: string, message?: string) {
|
||||
res.type('text/html; charset=utf-8');
|
||||
res.header('location', location);
|
||||
res.header('content-language', 'en-us');
|
||||
res.status(302);
|
||||
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
|
||||
}
|
||||
|
||||
export function redirect_303_see_other(res: FastifyReply, location: string, message?: string) {
|
||||
res.type('text/html; charset=utf-8');
|
||||
res.header('location', location);
|
||||
res.header('content-language', 'en-us');
|
||||
res.status(303);
|
||||
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
|
||||
}
|
||||
|
||||
export function redirect_307_temporary_redirect(res: FastifyReply, location: string, message?: string) {
|
||||
res.type('text/html; charset=utf-8');
|
||||
res.header('location', location);
|
||||
res.header('content-language', 'en-us');
|
||||
res.status(303);
|
||||
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
|
||||
}
|
||||
|
||||
export function redirect_200_refresh(res: FastifyReply, location: string, message?: string) {
|
||||
res.type('text/html; charset=utf-8');
|
||||
res.header('location', location);
|
||||
res.header('content-language', 'en-us');
|
||||
res.header('refresh', `0;URL='${location}'`);
|
||||
res.status(200);
|
||||
res.send(`
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;URL='${location}'"/>
|
||||
</head>
|
||||
<body>
|
||||
<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
@@ -1,12 +1,13 @@
|
||||
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { RouteGenericInterface } from 'fastify/types/route';
|
||||
import { SessionKey } from '../security/session-key';
|
||||
import { SessionData } from '../storage';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { RouteGenericInterface } from 'fastify/types/route';
|
||||
import type { SessionKey } from '../security/session';
|
||||
import type { SessionData, UserData } from '../storage';
|
||||
|
||||
export type Req<T = RouteGenericInterface> = FastifyRequest<T> & {
|
||||
session?: {
|
||||
key: SessionKey;
|
||||
data: SessionData;
|
||||
user: UserData;
|
||||
};
|
||||
};
|
||||
|
75
src/http/send-error.ts
Normal file
75
src/http/send-error.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
|
||||
import { FastifyReply } from 'fastify';
|
||||
import * as sch from '../utilities/json-schema';
|
||||
|
||||
export interface JsonErrorContent {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
help?: string;
|
||||
}
|
||||
|
||||
export const error_content_schema = (errors: ErrorCode[]) => sch.obj({
|
||||
code: sch.str_enum(errors),
|
||||
message: sch.str(),
|
||||
help: sch.str('uri'),
|
||||
}, { required: [ 'code', 'message' ] });
|
||||
|
||||
export function send_json_error(res: FastifyReply, error_code: ErrorCode) {
|
||||
const error = errors[error_code];
|
||||
res.status(error.status);
|
||||
res.header('content-type', 'application/json; charset=utf-8');
|
||||
|
||||
if (error.help_link) {
|
||||
res.header('link', `<${error.help_link}>; rel="help"`);
|
||||
}
|
||||
|
||||
return {
|
||||
code: error_code,
|
||||
message: error.message,
|
||||
help: error.help_link,
|
||||
};
|
||||
}
|
||||
|
||||
export function send_html_error(res: FastifyReply, error_code: ErrorCode, render_html: (code: ErrorCode, error: ErrorInfo) => string) {
|
||||
const error = errors[error_code];
|
||||
res.status(error.status);
|
||||
res.header('content-type', 'text/html; charset=utf-8');
|
||||
|
||||
if (error.help_link) {
|
||||
res.header('link', `<${error.help_link}>; rel="help"`);
|
||||
}
|
||||
|
||||
return render_html(error_code, error);
|
||||
}
|
||||
|
||||
export interface ErrorInfo {
|
||||
status: number;
|
||||
message: string;
|
||||
help_link?: string;
|
||||
}
|
||||
|
||||
export type ErrorCode
|
||||
= 'oidc_no_pkce_code_verifier'
|
||||
| 'oidc_callback_params_invalid'
|
||||
| 'oidc_token_fetch_failed'
|
||||
| 'oidc_userinfo_fetch_failed'
|
||||
;
|
||||
|
||||
const errors: Record<ErrorCode, ErrorInfo> = Object.freeze({
|
||||
oidc_no_pkce_code_verifier: {
|
||||
status: 401,
|
||||
message: 'No PKCE code verifier provided (are cookies working correctly?)',
|
||||
},
|
||||
oidc_callback_params_invalid: {
|
||||
status: 401,
|
||||
message: 'Login callback parameters invalid',
|
||||
},
|
||||
oidc_token_fetch_failed: {
|
||||
status: 401,
|
||||
message: 'Failed to fetch token set from OpenID Connect provider',
|
||||
},
|
||||
oidc_userinfo_fetch_failed: {
|
||||
status: 401,
|
||||
message: 'Failed to fetch userinfo from OpenID Connect provider',
|
||||
},
|
||||
});
|
100
src/http/server.ts
Normal file
100
src/http/server.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
|
||||
import fastify, { FastifyInstance } from 'fastify';
|
||||
import etags from '@fastify/etag';
|
||||
import compress, { FastifyCompressOptions } from '@fastify/compress';
|
||||
import formbody from '@fastify/formbody';
|
||||
import { pino } from 'pino';
|
||||
|
||||
export type ServerStatus = 'unstarted' | 'starting' | 'ready' | 'closing' | 'closed';
|
||||
|
||||
export interface HttpConfig {
|
||||
address: string;
|
||||
port: number;
|
||||
tls: false | {
|
||||
key: string;
|
||||
cert: string;
|
||||
};
|
||||
exposed_url?: string;
|
||||
etag?: boolean;
|
||||
compress?: false | FastifyCompressOptions['encodings'];
|
||||
cache_control?: {
|
||||
static_assets: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BaseHttpDependencies {
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
export interface RegisterHttpEndpoint<Deps extends BaseHttpDependencies> {
|
||||
(server: FastifyInstance, conf: HttpConfig, deps: Deps): void;
|
||||
}
|
||||
|
||||
export interface ContentParser {
|
||||
(server: FastifyInstance, media_types: string[]): void;
|
||||
}
|
||||
|
||||
export interface HttpParams<Deps extends BaseHttpDependencies> {
|
||||
content_parsers: Record<string, ContentParser>;
|
||||
endpoints: RegisterHttpEndpoint<Deps>[];
|
||||
}
|
||||
|
||||
export function create_http_server<Deps extends BaseHttpDependencies>(conf: HttpConfig, deps: Deps, params: HttpParams<Deps>) {
|
||||
const server = fastify({
|
||||
logger: deps.logger
|
||||
});
|
||||
|
||||
// Register error handlers
|
||||
// todo: register error handlers...
|
||||
|
||||
// Register endpoints
|
||||
for (const endpoint of params.endpoints) {
|
||||
endpoint(server, conf, deps);
|
||||
}
|
||||
|
||||
let resolve: () => void;
|
||||
let status: ServerStatus = 'unstarted';
|
||||
|
||||
const ready = new Promise<void>((onResolve) => {
|
||||
resolve = onResolve;
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
ready,
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
|
||||
//
|
||||
async setup_plugins() {
|
||||
await Promise.all([
|
||||
server.register(formbody),
|
||||
conf.etag ? server.register(etags) : null,
|
||||
conf.compress ? server.register(compress, { encodings: conf.compress }) : null,
|
||||
]);
|
||||
},
|
||||
|
||||
async listen() {
|
||||
status = 'starting';
|
||||
|
||||
try {
|
||||
await server.listen({ port: conf.port, host: conf.address });
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
server.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await server.ready();
|
||||
status = 'ready';
|
||||
resolve();
|
||||
},
|
||||
async close() {
|
||||
status = 'closing';
|
||||
await server.close();
|
||||
status = 'closed';
|
||||
},
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user