work on login flow

This commit is contained in:
2023-07-23 16:04:49 -07:00
parent 13457ec125
commit 7addce60bb
39 changed files with 1499 additions and 175 deletions

View 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`);
}

View 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
View 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>
`);
}

View File

@@ -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
View 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
View 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';
},
};
}