store express sessions in redis, delete only aliases from preferred domain #3

Merged
lauri merged 5 commits from dev into master 2023-08-12 08:14:49 +00:00
25 changed files with 524 additions and 591 deletions
Showing only changes of commit feb5e5b4ca - Show all commits

9
.prettierrc.json Normal file
View File

@ -0,0 +1,9 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"printWidth": 120,
"quoteProps": "as-needed",
"arrowParens": "avoid"
}

View File

@ -1,33 +1,33 @@
import { app } from "./app"; import { app } from './app';
import { logger } from "./logger"; import { logger } from './logger';
const port = app.get("port"); const port = app.get('port');
const host = app.get("host"); const host = app.get('host');
const server = app.listen(port); const server = app.listen(port);
app.listen(port).then(() => { app.listen(port).then(() => {
logger.info(`Walias app listening on http://${host}:${port}`); logger.info(`Walias app listening on http://${host}:${port}`);
}); });
process.on("SIGINT", () => { process.on('SIGINT', () => {
logger.info("Received SIGINT signal. Shutting down gracefully."); logger.info('Received SIGINT signal. Shutting down gracefully.');
server.close(() => { server.close(() => {
logger.info("HTTP server closed."); logger.info('HTTP server closed.');
process.exit(0); process.exit(0);
}); });
}); });
process.on("SIGTERM", () => { process.on('SIGTERM', () => {
logger.info("Received SIGTERM signal. Shutting down gracefully."); logger.info('Received SIGTERM signal. Shutting down gracefully.');
server.close(() => { server.close(() => {
logger.info("HTTP server closed."); logger.info('HTTP server closed.');
process.exit(0); process.exit(0);
}); });
}); });
process.on("unhandledRejection", (reason) => { process.on('unhandledRejection', reason => {
logger.error("Unhandled rejection", reason); logger.error('Unhandled rejection', reason);
process.exit(1); process.exit(1);
}); });

View File

@ -1,10 +1,10 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/logging.html // For more information about this file see https://dove.feathersjs.com/guides/cli/logging.html
import { createLogger, format, transports } from "winston"; import { createLogger, format, transports } from 'winston';
// Configure the Winston logger. For the complete documentation see https://github.com/winstonjs/winston // Configure the Winston logger. For the complete documentation see https://github.com/winstonjs/winston
export const logger = createLogger({ export const logger = createLogger({
// To see more detailed errors, change this to 'debug' // To see more detailed errors, change this to 'debug'
level: "info", level: 'info',
format: format.combine(format.splat(), format.simple()), format: format.combine(format.splat(), format.simple()),
transports: [new transports.Console()], transports: [new transports.Console()],
}); });

View File

@ -1,27 +1,19 @@
import { randomUUID } from "crypto"; import { randomUUID } from 'crypto';
import { feathers } from "@feathersjs/feathers"; import { feathers } from '@feathersjs/feathers';
import express, { import express, { rest, json, urlencoded, cors, serveStatic, notFound, errorHandler } from '@feathersjs/express';
rest, import configuration from '@feathersjs/configuration';
json, import socketio from '@feathersjs/socketio';
urlencoded, import session from 'express-session';
cors, import cookieParser from 'cookie-parser';
serveStatic, import RedisStore from 'connect-redis';
notFound, import { createClient } from 'redis';
errorHandler, import config from 'config';
} from "@feathersjs/express"; import type { Application } from './declarations';
import configuration from "@feathersjs/configuration"; import { logger } from './logger';
import socketio from "@feathersjs/socketio"; import { logError } from './hooks/log-error';
import session from "express-session"; import { services } from './services/index';
import cookieParser from "cookie-parser"; import { channels } from './channels';
import RedisStore from "connect-redis"; import { Env, getEnv } from './helpers/get-env';
import { createClient } from "redis";
import config from "config";
import type { Application } from "./declarations";
import { logger } from "./logger";
import { logError } from "./hooks/log-error";
import { services } from "./services/index";
import { channels } from "./channels";
import { Env, getEnv } from "./helpers/get-env";
const app: Application = express(feathers()); const app: Application = express(feathers());
@ -29,54 +21,54 @@ const app: Application = express(feathers());
app.configure(configuration()); app.configure(configuration());
app.use(cors()); app.use(cors());
app.use( app.use(
json({ json({
limit: "20mb", limit: '20mb',
}), })
); );
app.use(cookieParser()); app.use(cookieParser());
const sessionStore = const sessionStore =
getEnv() === Env.prod getEnv() === Env.prod
? new RedisStore({ ? new RedisStore({
prefix: "walias:", prefix: 'walias:',
client: createClient({ client: createClient({
url: config.get("redis.url"), url: config.get('redis.url'),
}), }),
}) })
: undefined; : undefined;
app.use( app.use(
session({ session({
store: sessionStore, store: sessionStore,
secret: randomUUID(), secret: randomUUID(),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { secure: false }, cookie: { secure: false },
}), })
); );
// Propagate session to request.params in feathers services // Propagate session to request.params in feathers services
app.use(function (req, _res, next) { app.use(function (req, _res, next) {
req.feathers = { req.feathers = {
...req.feathers, ...req.feathers,
session: req.session, session: req.session,
}; };
next(); next();
}); });
app.use(urlencoded({ extended: true })); app.use(urlencoded({ extended: true }));
// Host the public folder // Host the public folder
app.use("/", serveStatic(app.get("public"))); app.use('/', serveStatic(app.get('public')));
// Configure services and real-time functionality // Configure services and real-time functionality
app.configure(rest()); app.configure(rest());
app.configure( app.configure(
socketio({ socketio({
cors: { cors: {
origin: app.get("origins"), origin: app.get('origins'),
}, },
}), })
); );
app.configure(services); app.configure(services);
app.configure(channels); app.configure(channels);
@ -87,17 +79,17 @@ app.use(errorHandler({ logger }));
// Register hooks that run on all service methods // Register hooks that run on all service methods
app.hooks({ app.hooks({
around: { around: {
all: [logError], all: [logError],
}, },
before: {}, before: {},
after: {}, after: {},
error: {}, error: {},
}); });
// Register application setup and teardown hooks here // Register application setup and teardown hooks here
app.hooks({ app.hooks({
setup: [], setup: [],
teardown: [], teardown: [],
}); });
export { app }; export { app };

View File

@ -1,41 +1,38 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/channels.html // For more information about this file see https://dove.feathersjs.com/guides/cli/channels.html
import type { RealTimeConnection, Params } from "@feathersjs/feathers"; import type { RealTimeConnection, Params } from '@feathersjs/feathers';
import type { AuthenticationResult } from "@feathersjs/authentication"; import type { AuthenticationResult } from '@feathersjs/authentication';
import "@feathersjs/transport-commons"; import '@feathersjs/transport-commons';
import type { Application, HookContext } from "./declarations"; import type { Application, HookContext } from './declarations';
import { logger } from "./logger"; import { logger } from './logger';
export const channels = (app: Application) => { export const channels = (app: Application) => {
logger.warn( logger.warn(
"Publishing all events to all authenticated users. See `channels.ts` and https://dove.feathersjs.com/api/channels.html for more information.", 'Publishing all events to all authenticated users. See `channels.ts` and https://dove.feathersjs.com/api/channels.html for more information.'
); );
app.on("connection", (connection: RealTimeConnection) => { app.on('connection', (connection: RealTimeConnection) => {
// On a new real-time connection, add it to the anonymous channel // On a new real-time connection, add it to the anonymous channel
app.channel("anonymous").join(connection); app.channel('anonymous').join(connection);
}); });
app.on( app.on('login', (authResult: AuthenticationResult, { connection }: Params) => {
"login", // connection can be undefined if there is no
(authResult: AuthenticationResult, { connection }: Params) => { // real-time connection, e.g. when logging in via REST
// connection can be undefined if there is no if (connection) {
// real-time connection, e.g. when logging in via REST // The connection is no longer anonymous, remove it
if (connection) { app.channel('anonymous').leave(connection);
// The connection is no longer anonymous, remove it
app.channel("anonymous").leave(connection);
// Add it to the authenticated user channel // Add it to the authenticated user channel
app.channel("authenticated").join(connection); app.channel('authenticated').join(connection);
} }
}, });
);
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
app.publish((data: any, context: HookContext) => { app.publish((data: any, context: HookContext) => {
// Here you can add event publishers to channels set up in `channels.js` // Here you can add event publishers to channels set up in `channels.js`
// To publish only for a specific event use `app.publish(eventname, () => {})` // To publish only for a specific event use `app.publish(eventname, () => {})`
// e.g. to publish all service events to all authenticated users use // e.g. to publish all service events to all authenticated users use
return app.channel("authenticated"); return app.channel('authenticated');
}); });
}; };

View File

@ -1,12 +1,12 @@
import axios from "axios"; import axios from 'axios';
import config from "config"; import config from 'config';
const wildDuckClient = axios.create({ const wildDuckClient = axios.create({
baseURL: config.get("wildDuck.url"), baseURL: config.get('wildDuck.url'),
headers: { headers: {
"X-Access-Token": config.get("wildDuck.token"), 'X-Access-Token': config.get('wildDuck.token'),
}, },
responseType: "json", responseType: 'json',
}); });
export default wildDuckClient; export default wildDuckClient;

View File

@ -1,9 +1,6 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/typescript.html // For more information about this file see https://dove.feathersjs.com/guides/cli/typescript.html
import { import { HookContext as FeathersHookContext, NextFunction } from '@feathersjs/feathers';
HookContext as FeathersHookContext, import { Application as FeathersApplication } from '@feathersjs/express';
NextFunction,
} from "@feathersjs/feathers";
import { Application as FeathersApplication } from "@feathersjs/express";
type ApplicationConfiguration = any; type ApplicationConfiguration = any;
export { NextFunction }; export { NextFunction };

View File

@ -1,16 +1,16 @@
export enum Env { export enum Env {
dev = "dev", dev = 'dev',
prod = "prod", prod = 'prod',
test = "test", test = 'test',
} }
export const getEnv = (): Env => { export const getEnv = (): Env => {
const env = process.env.NODE_ENV; const env = process.env.NODE_ENV;
if (env === "prod") { if (env === 'prod') {
return Env.prod; return Env.prod;
} else if (env === "test") { } else if (env === 'test') {
return Env.test; return Env.test;
} else { } else {
return Env.dev; return Env.dev;
} }
}; };

View File

@ -1,17 +1,17 @@
import type { HookContext, NextFunction } from "../declarations"; import type { HookContext, NextFunction } from '../declarations';
import { logger } from "../logger"; import { logger } from '../logger';
export const logError = async (context: HookContext, next: NextFunction) => { export const logError = async (context: HookContext, next: NextFunction) => {
try { try {
await next(); await next();
} catch (error: any) { } catch (error: any) {
logger.error(error.stack); logger.error(error.stack);
// Log validation errors // Log validation errors
if (error.data) { if (error.data) {
logger.error("Data: %O", error.data); logger.error('Data: %O', error.data);
}
throw error;
} }
throw error;
}
}; };

View File

@ -1,9 +1,9 @@
import { NotAuthenticated } from "@feathersjs/errors"; import { NotAuthenticated } from '@feathersjs/errors';
import type { HookContext, NextFunction } from "../declarations"; import type { HookContext, NextFunction } from '../declarations';
// Check if user is stored in session // Check if user is stored in session
export const validateAuth = async (context: HookContext) => { export const validateAuth = async (context: HookContext) => {
if (!context.params.session?.user) { if (!context.params.session?.user) {
throw new NotAuthenticated("Not authenticated"); throw new NotAuthenticated('Not authenticated');
} }
}; };

View File

@ -1,13 +1,11 @@
import { app } from "./app"; import { app } from './app';
import { logger } from "./logger"; import { logger } from './logger';
const port = app.get("port"); const port = app.get('port');
const host = app.get("host"); const host = app.get('host');
process.on("unhandledRejection", (reason) => process.on('unhandledRejection', reason => logger.error('Unhandled Rejection %O', reason));
logger.error("Unhandled Rejection %O", reason),
);
app.listen(port).then(() => { app.listen(port).then(() => {
logger.info(`Feathers app listening on http://${host}:${port}`); logger.info(`Feathers app listening on http://${host}:${port}`);
}); });

View File

@ -1,10 +1,10 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/logging.html // For more information about this file see https://dove.feathersjs.com/guides/cli/logging.html
import { createLogger, format, transports } from "winston"; import { createLogger, format, transports } from 'winston';
// Configure the Winston logger. For the complete documentation see https://github.com/winstonjs/winston // Configure the Winston logger. For the complete documentation see https://github.com/winstonjs/winston
export const logger = createLogger({ export const logger = createLogger({
// To see more detailed errors, change this to 'debug' // To see more detailed errors, change this to 'debug'
level: "info", level: 'info',
format: format.combine(format.splat(), format.simple()), format: format.combine(format.splat(), format.simple()),
transports: [new transports.Console()], transports: [new transports.Console()],
}); });

View File

@ -1,186 +1,142 @@
import type { import type { NullableId, Params, ServiceInterface } from '@feathersjs/feathers';
NullableId,
Params,
ServiceInterface,
} from "@feathersjs/feathers";
import type { Application } from "../../declarations"; import type { Application } from '../../declarations';
import wildDuckClient from "../../clients/wildduck.client"; import wildDuckClient from '../../clients/wildduck.client';
import { faker, th } from "@faker-js/faker"; import { faker, th } from '@faker-js/faker';
import { BadRequest } from "@feathersjs/errors"; import { BadRequest } from '@feathersjs/errors';
import config from "config"; import config from 'config';
interface WildDuckAddress { interface WildDuckAddress {
success: boolean; success: boolean;
id: string; id: string;
address: string; address: string;
main: boolean; main: boolean;
user: string; user: string;
tags: string[]; tags: string[];
created: string; created: string;
} }
interface GetWildDuckAddressInfoResponse { interface GetWildDuckAddressInfoResponse {
success: boolean; success: boolean;
results: WildDuckAddress[]; results: WildDuckAddress[];
} }
interface AliasApiResponse { interface AliasApiResponse {
id: string | null; id: string | null;
address: string; address: string;
tags: string[]; tags: string[];
created: string; created: string;
} }
interface CreateWildDuckAddressResponse { interface CreateWildDuckAddressResponse {
success: boolean; success: boolean;
id: string; id: string;
} }
type AliasesData = any; type AliasesData = any;
type AliasesPatch = any; type AliasesPatch = any;
type AliasesQuery = any; type AliasesQuery = any;
export type { export type { WildDuckAddress as Aliases, AliasesData, AliasesPatch, AliasesQuery };
WildDuckAddress as Aliases,
AliasesData,
AliasesPatch,
AliasesQuery,
};
export interface AliasesServiceOptions { export interface AliasesServiceOptions {
app: Application; app: Application;
} }
export interface AliasesParams extends Params<AliasesQuery> { export interface AliasesParams extends Params<AliasesQuery> {
session?: any; session?: any;
} }
export class AliasesService<ServiceParams extends AliasesParams = AliasesParams> export class AliasesService<ServiceParams extends AliasesParams = AliasesParams>
implements implements ServiceInterface<AliasApiResponse, AliasesData, ServiceParams, AliasesPatch>
ServiceInterface<
AliasApiResponse,
AliasesData,
ServiceParams,
AliasesPatch
>
{ {
constructor(public options: AliasesServiceOptions) {} constructor(public options: AliasesServiceOptions) {}
async find(params: ServiceParams): Promise<AliasApiResponse[]> { async find(params: ServiceParams): Promise<AliasApiResponse[]> {
const userId = await this.getUserIdByEmailAddress(params); const userId = await this.getUserIdByEmailAddress(params);
return this.getUserAddresses(userId); return this.getUserAddresses(userId);
}
async create(
data: AliasesData,
params: ServiceParams,
): Promise<AliasApiResponse>;
async create(
data: AliasesData,
params: ServiceParams,
): Promise<AliasApiResponse | AliasApiResponse[]> {
const userId = await this.getUserIdByEmailAddress(params);
const randomString = faker.git.commitSha({ length: 4 });
// Replace all non-alphanumeric characters with nothing and spaces with dashes
const alias =
`${faker.color.human()}-${faker.animal.snake()}-${randomString}`
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9-]/g, "")
.toLowerCase();
const emailDomain = config.get("wildDuck.domain");
const createResult =
await wildDuckClient.post<CreateWildDuckAddressResponse>(
`/users/${userId}/addresses`,
{
address: `${alias}@${emailDomain}`,
},
);
if (!createResult.data.success) {
throw new BadRequest("Failed to create alias");
} }
return this.getUserAddresses(userId); async create(data: AliasesData, params: ServiceParams): Promise<AliasApiResponse>;
} async create(data: AliasesData, params: ServiceParams): Promise<AliasApiResponse | AliasApiResponse[]> {
const userId = await this.getUserIdByEmailAddress(params);
private async getUserIdByEmailAddress( const randomString = faker.git.commitSha({ length: 4 });
params: ServiceParams,
): Promise<string> {
const emails = params.session?.user?.emails;
const preferredDomain = config.get("wildDuck.preferredDomain"); // Replace all non-alphanumeric characters with nothing and spaces with dashes
const alias = `${faker.color.human()}-${faker.animal.snake()}-${randomString}`
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '')
.toLowerCase();
if (!emails.length || !preferredDomain) { const emailDomain = config.get('wildDuck.domain');
throw new BadRequest("Unable to find user");
const createResult = await wildDuckClient.post<CreateWildDuckAddressResponse>(`/users/${userId}/addresses`, {
address: `${alias}@${emailDomain}`,
});
if (!createResult.data.success) {
throw new BadRequest('Failed to create alias');
}
return this.getUserAddresses(userId);
} }
const addressInfoResponse = await Promise.any( private async getUserIdByEmailAddress(params: ServiceParams): Promise<string> {
emails const emails = params.session?.user?.emails;
.filter((email: string) =>
email.endsWith(config.get("wildDuck.preferredDomain")),
)
.map((email: string) =>
wildDuckClient.get<WildDuckAddress>(`addresses/resolve/${email}`),
),
);
return addressInfoResponse.data.user; const preferredDomain = config.get('wildDuck.preferredDomain');
}
private async getUserAddresses(userId: string): Promise<AliasApiResponse[]> { if (!emails.length || !preferredDomain) {
const { data: userAddressesResponse } = throw new BadRequest('Unable to find user');
await wildDuckClient.get<GetWildDuckAddressInfoResponse>( }
`/users/${userId}/addresses`,
);
return userAddressesResponse.results.map(this.sanitizeAliasResponse); const addressInfoResponse = await Promise.any(
} emails
.filter((email: string) => email.endsWith(config.get('wildDuck.preferredDomain')))
.map((email: string) => wildDuckClient.get<WildDuckAddress>(`addresses/resolve/${email}`))
);
async remove( return addressInfoResponse.data.user;
id: NullableId,
params: ServiceParams,
): Promise<AliasApiResponse[]> {
const { data: addressInfoResponse } =
await wildDuckClient.get<WildDuckAddress>(`addresses/resolve/${id}`);
const allowedDomain: string = config.get("wildDuck.domain");
// If address does not match the allowed domain, throw an error
if (
!allowedDomain ||
!addressInfoResponse.address.endsWith(allowedDomain)
) {
throw new BadRequest("Unable to delete address");
} }
const userId = await this.getUserIdByEmailAddress(params);
await wildDuckClient.delete<WildDuckAddress>( private async getUserAddresses(userId: string): Promise<AliasApiResponse[]> {
`users/${userId}/addresses/${id}`, const { data: userAddressesResponse } = await wildDuckClient.get<GetWildDuckAddressInfoResponse>(
); `/users/${userId}/addresses`
);
return this.getUserAddresses(userId); return userAddressesResponse.results.map(this.sanitizeAliasResponse);
} }
sanitizeAliasResponse(alias: WildDuckAddress): AliasApiResponse { async remove(id: NullableId, params: ServiceParams): Promise<AliasApiResponse[]> {
// Hide the id if the alias is not removable const { data: addressInfoResponse } = await wildDuckClient.get<WildDuckAddress>(`addresses/resolve/${id}`);
const isRemovable = const allowedDomain: string = config.get('wildDuck.domain');
alias.main ||
!alias.address.endsWith(config.get("wildDuck.preferredDomain"));
return { // If address does not match the allowed domain, throw an error
id: isRemovable ? null : alias.id, if (!allowedDomain || !addressInfoResponse.address.endsWith(allowedDomain)) {
address: alias.address, throw new BadRequest('Unable to delete address');
tags: alias.tags, }
created: alias.created, const userId = await this.getUserIdByEmailAddress(params);
};
} await wildDuckClient.delete<WildDuckAddress>(`users/${userId}/addresses/${id}`);
return this.getUserAddresses(userId);
}
sanitizeAliasResponse(alias: WildDuckAddress): AliasApiResponse {
// Hide the id if the alias is not removable
const isRemovable = alias.main || !alias.address.endsWith(config.get('wildDuck.preferredDomain'));
return {
id: isRemovable ? null : alias.id,
address: alias.address,
tags: alias.tags,
created: alias.created,
};
}
} }
export const getOptions = (app: Application) => { export const getOptions = (app: Application) => {
return { app }; return { app };
}; };

View File

@ -1,39 +1,39 @@
import type { Application } from "../../declarations"; import type { Application } from '../../declarations';
import { validateAuth } from "../../hooks/validate-auth"; import { validateAuth } from '../../hooks/validate-auth';
import { AliasesService, getOptions } from "./aliases.class"; import { AliasesService, getOptions } from './aliases.class';
export const aliasesPath = "aliases"; export const aliasesPath = 'aliases';
export const aliasesMethods = ["find", "create", "remove"] as const; export const aliasesMethods = ['find', 'create', 'remove'] as const;
export * from "./aliases.class"; export * from './aliases.class';
export const aliases = (app: Application) => { export const aliases = (app: Application) => {
app.use(aliasesPath, new AliasesService(getOptions(app)), { app.use(aliasesPath, new AliasesService(getOptions(app)), {
methods: aliasesMethods, methods: aliasesMethods,
events: [], events: [],
}); });
app.service(aliasesPath).hooks({ app.service(aliasesPath).hooks({
around: { around: {
all: [], all: [],
}, },
before: { before: {
all: [validateAuth], all: [validateAuth],
find: [], find: [],
create: [], create: [],
}, },
after: { after: {
all: [], all: [],
}, },
error: { error: {
all: [], all: [],
}, },
}); });
}; };
// Add this service to the service type index // Add this service to the service type index
declare module "../../declarations" { declare module '../../declarations' {
interface ServiceTypes { interface ServiceTypes {
[aliasesPath]: AliasesService; [aliasesPath]: AliasesService;
} }
} }

View File

@ -1,9 +1,9 @@
import type { Params, ServiceInterface } from "@feathersjs/feathers"; import type { Params, ServiceInterface } from '@feathersjs/feathers';
import type { Application } from "../../declarations"; import type { Application } from '../../declarations';
import { Issuer, generators } from "openid-client"; import { Issuer, generators } from 'openid-client';
import config from "config"; import config from 'config';
type AuthOidcResponse = string; type AuthOidcResponse = string;
type AuthOidcQuery = any; type AuthOidcQuery = any;
@ -11,43 +11,42 @@ type AuthOidcQuery = any;
export type { AuthOidcResponse as AuthOidc, AuthOidcQuery }; export type { AuthOidcResponse as AuthOidc, AuthOidcQuery };
export interface AuthOidcServiceOptions { export interface AuthOidcServiceOptions {
app: Application; app: Application;
} }
export interface AuthOidcParams extends Params<AuthOidcQuery> { export interface AuthOidcParams extends Params<AuthOidcQuery> {
session?: any; session?: any;
} }
export class AuthOidcService< export class AuthOidcService<ServiceParams extends AuthOidcParams = AuthOidcParams>
ServiceParams extends AuthOidcParams = AuthOidcParams, implements ServiceInterface<AuthOidcResponse, ServiceParams>
> implements ServiceInterface<AuthOidcResponse, ServiceParams>
{ {
constructor(public options: AuthOidcServiceOptions) {} constructor(public options: AuthOidcServiceOptions) {}
async find(params: ServiceParams): Promise<AuthOidcResponse> { async find(params: ServiceParams): Promise<AuthOidcResponse> {
const issuer = await Issuer.discover(config.get("oidc.gatewayUri")); const issuer = await Issuer.discover(config.get('oidc.gatewayUri'));
const client = new issuer.Client({ const client = new issuer.Client({
client_id: config.get("oidc.clientId"), client_id: config.get('oidc.clientId'),
client_secret: config.get("oidc.clientSecret"), client_secret: config.get('oidc.clientSecret'),
redirect_uris: [config.get("oidc.redirectUris")], redirect_uris: [config.get('oidc.redirectUris')],
response_types: ["code"], response_types: ['code'],
}); });
const codeVerifier = generators.codeVerifier(); const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier); const codeChallenge = generators.codeChallenge(codeVerifier);
const url = client.authorizationUrl({ const url = client.authorizationUrl({
redirect_uri: config.get("clientUrl") + "/auth-oidc/callback", redirect_uri: config.get('clientUrl') + '/auth-oidc/callback',
scope: "openid profile offline_access", scope: 'openid profile offline_access',
response_type: "code", response_type: 'code',
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: "S256", code_challenge_method: 'S256',
}); });
params.session.codeVerifier = codeVerifier; params.session.codeVerifier = codeVerifier;
return url; return url;
} }
} }
export const getOptions = (app: Application) => { export const getOptions = (app: Application) => {
return { app }; return { app };
}; };

View File

@ -1,45 +1,45 @@
import type { Application } from "../../declarations"; import type { Application } from '../../declarations';
import { AuthOidcService, getOptions } from "./auth-oidc.class"; import { AuthOidcService, getOptions } from './auth-oidc.class';
export const authOidcPath = "auth-oidc"; export const authOidcPath = 'auth-oidc';
export const authOidcMethods = ["find"] as const; export const authOidcMethods = ['find'] as const;
export * from "./auth-oidc.class"; export * from './auth-oidc.class';
export const authOidc = (app: Application) => { export const authOidc = (app: Application) => {
// TODO: fix this to use the correct type // TODO: fix this to use the correct type
// @ts-ignore // @ts-ignore
app.use( app.use(
authOidcPath, authOidcPath,
new AuthOidcService(getOptions(app)), new AuthOidcService(getOptions(app)),
{ {
methods: authOidcMethods, methods: authOidcMethods,
events: [], events: [],
}, },
(req: any, res: any) => { (req: any, res: any) => {
return res.redirect(res.data); return res.redirect(res.data);
}, }
); );
app.service(authOidcPath).hooks({ app.service(authOidcPath).hooks({
around: { around: {
all: [], all: [],
}, },
before: { before: {
all: [], all: [],
find: [], find: [],
}, },
after: { after: {
all: [], all: [],
}, },
error: { error: {
all: [], all: [],
}, },
}); });
}; };
declare module "../../declarations" { declare module '../../declarations' {
interface ServiceTypes { interface ServiceTypes {
[authOidcPath]: AuthOidcService; [authOidcPath]: AuthOidcService;
} }
} }

View File

@ -1,68 +1,56 @@
import type { Params, ServiceInterface } from "@feathersjs/feathers"; import type { Params, ServiceInterface } from '@feathersjs/feathers';
import type { Application } from "../../../declarations"; import type { Application } from '../../../declarations';
import { Issuer } from "openid-client"; import { Issuer } from 'openid-client';
import config from "config"; import config from 'config';
type AuthOidcCallback = string; type AuthOidcCallback = string;
type AuthOidcCallbackData = any; type AuthOidcCallbackData = any;
type AuthOidcCallbackPatch = any; type AuthOidcCallbackPatch = any;
type AuthOidcCallbackQuery = any; type AuthOidcCallbackQuery = any;
export type { export type { AuthOidcCallback, AuthOidcCallbackData, AuthOidcCallbackPatch, AuthOidcCallbackQuery };
AuthOidcCallback,
AuthOidcCallbackData,
AuthOidcCallbackPatch,
AuthOidcCallbackQuery,
};
export interface AuthOidcCallbackServiceOptions { export interface AuthOidcCallbackServiceOptions {
app: Application; app: Application;
} }
export interface AuthOidcCallbackParams extends Params<AuthOidcCallbackQuery> { export interface AuthOidcCallbackParams extends Params<AuthOidcCallbackQuery> {
session?: any; session?: any;
query: { query: {
iss: string; iss: string;
code: string; code: string;
}; };
} }
export class AuthOidcCallbackService< export class AuthOidcCallbackService<ServiceParams extends AuthOidcCallbackParams = AuthOidcCallbackParams>
ServiceParams extends AuthOidcCallbackParams = AuthOidcCallbackParams, implements ServiceInterface<AuthOidcCallback, AuthOidcCallbackData, ServiceParams, AuthOidcCallbackPatch>
> implements
ServiceInterface<
AuthOidcCallback,
AuthOidcCallbackData,
ServiceParams,
AuthOidcCallbackPatch
>
{ {
constructor(public options: AuthOidcCallbackServiceOptions) {} constructor(public options: AuthOidcCallbackServiceOptions) {}
async find(params: ServiceParams): Promise<AuthOidcCallback> { async find(params: ServiceParams): Promise<AuthOidcCallback> {
const issuer = await Issuer.discover(config.get("oidc.gatewayUri")); const issuer = await Issuer.discover(config.get('oidc.gatewayUri'));
const client = new issuer.Client({ const client = new issuer.Client({
client_id: config.get("oidc.clientId"), client_id: config.get('oidc.clientId'),
client_secret: config.get("oidc.clientSecret"), client_secret: config.get('oidc.clientSecret'),
redirect_uris: [config.get("oidc.redirectUris")], redirect_uris: [config.get('oidc.redirectUris')],
response_types: ["code"], response_types: ['code'],
}); });
const codeVerifier = params.session.codeVerifier; const codeVerifier = params.session.codeVerifier;
const tokenSet = await client.callback( const tokenSet = await client.callback(
config.get("clientUrl") + "/auth-oidc/callback", config.get('clientUrl') + '/auth-oidc/callback',
{ code: params.query.code, iss: params.query.iss }, { code: params.query.code, iss: params.query.iss },
{ code_verifier: codeVerifier }, { code_verifier: codeVerifier }
); );
const userinfo = await client.userinfo(tokenSet.access_token as string); const userinfo = await client.userinfo(tokenSet.access_token as string);
params.session.user = userinfo; params.session.user = userinfo;
return "/"; return '/';
} }
} }
export const getOptions = (app: Application) => { export const getOptions = (app: Application) => {
return { app }; return { app };
}; };

View File

@ -1,49 +1,46 @@
import { http } from "@feathersjs/transport-commons"; import { http } from '@feathersjs/transport-commons';
import type { Application } from "../../../declarations"; import type { Application } from '../../../declarations';
import { import { AuthOidcCallbackService, getOptions } from './auth-oidc-callback.class';
AuthOidcCallbackService,
getOptions,
} from "./auth-oidc-callback.class";
export const authOidcCallbackPath = "auth-oidc/callback"; export const authOidcCallbackPath = 'auth-oidc/callback';
export const authOidcCallbackMethods = ["find"] as const; export const authOidcCallbackMethods = ['find'] as const;
export * from "./auth-oidc-callback.class"; export * from './auth-oidc-callback.class';
export const authOidcCallback = (app: Application) => { export const authOidcCallback = (app: Application) => {
// TODO: fix this to use the correct type // TODO: fix this to use the correct type
// @ts-ignore // @ts-ignore
app.use( app.use(
authOidcCallbackPath, authOidcCallbackPath,
new AuthOidcCallbackService(getOptions(app)), new AuthOidcCallbackService(getOptions(app)),
{ {
methods: authOidcCallbackMethods, methods: authOidcCallbackMethods,
events: [], events: [],
}, },
(req: any, res: any) => { (req: any, res: any) => {
return res.redirect(res.data); return res.redirect(res.data);
}, }
); );
app.service(authOidcCallbackPath).hooks({ app.service(authOidcCallbackPath).hooks({
around: { around: {
all: [], all: [],
}, },
before: { before: {
all: [], all: [],
find: [], find: [],
}, },
after: { after: {
all: [], all: [],
}, },
error: { error: {
all: [], all: [],
}, },
}); });
}; };
declare module "../../../declarations" { declare module '../../../declarations' {
interface ServiceTypes { interface ServiceTypes {
[authOidcCallbackPath]: AuthOidcCallbackService; [authOidcCallbackPath]: AuthOidcCallbackService;
} }
} }

View File

@ -1,10 +1,10 @@
import { authOidcCallback } from "./auth-oidc/callback/auth-oidc-callback"; import { authOidcCallback } from './auth-oidc/callback/auth-oidc-callback';
import { authOidc } from "./auth-oidc/auth-oidc"; import { authOidc } from './auth-oidc/auth-oidc';
import { aliases } from "./aliases/aliases"; import { aliases } from './aliases/aliases';
import type { Application } from "../declarations"; import type { Application } from '../declarations';
export const services = (app: Application) => { export const services = (app: Application) => {
app.configure(authOidcCallback); app.configure(authOidcCallback);
app.configure(authOidc); app.configure(authOidc);
app.configure(aliases); app.configure(aliases);
}; };

View File

@ -1,29 +1,29 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/validators.html // For more information about this file see https://dove.feathersjs.com/guides/cli/validators.html
import { Ajv, addFormats } from "@feathersjs/schema"; import { Ajv, addFormats } from '@feathersjs/schema';
import type { FormatsPluginOptions } from "@feathersjs/schema"; import type { FormatsPluginOptions } from '@feathersjs/schema';
const formats: FormatsPluginOptions = [ const formats: FormatsPluginOptions = [
"date-time", 'date-time',
"time", 'time',
"date", 'date',
"email", 'email',
"hostname", 'hostname',
"ipv4", 'ipv4',
"ipv6", 'ipv6',
"uri", 'uri',
"uri-reference", 'uri-reference',
"uuid", 'uuid',
"uri-template", 'uri-template',
"json-pointer", 'json-pointer',
"relative-json-pointer", 'relative-json-pointer',
"regex", 'regex',
]; ];
export const dataValidator: Ajv = addFormats(new Ajv({}), formats); export const dataValidator: Ajv = addFormats(new Ajv({}), formats);
export const queryValidator: Ajv = addFormats( export const queryValidator: Ajv = addFormats(
new Ajv({ new Ajv({
coerceTypes: true, coerceTypes: true,
}), }),
formats, formats
); );

View File

@ -1,40 +1,40 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/app.test.html // For more information about this file see https://dove.feathersjs.com/guides/cli/app.test.html
import assert from "assert"; import assert from 'assert';
import axios from "axios"; import axios from 'axios';
import type { Server } from "http"; import type { Server } from 'http';
import { app } from "../src/app"; import { app } from '../src/app';
const port = app.get("port"); const port = app.get('port');
const appUrl = `http://${app.get("host")}:${port}`; const appUrl = `http://${app.get('host')}:${port}`;
describe("Feathers application tests", () => { describe('Feathers application tests', () => {
let server: Server; let server: Server;
before(async () => { before(async () => {
server = await app.listen(port); server = await app.listen(port);
}); });
after(async () => { after(async () => {
await app.teardown(); await app.teardown();
}); });
it("starts and shows the index page", async () => { it('starts and shows the index page', async () => {
const { data } = await axios.get<string>(appUrl); const { data } = await axios.get<string>(appUrl);
assert.ok(data.indexOf('<html lang="en">') !== -1); assert.ok(data.indexOf('<html lang="en">') !== -1);
}); });
it("shows a 404 JSON error", async () => { it('shows a 404 JSON error', async () => {
try { try {
await axios.get(`${appUrl}/path/to/nowhere`, { await axios.get(`${appUrl}/path/to/nowhere`, {
responseType: "json", responseType: 'json',
}); });
assert.fail("should never get here"); assert.fail('should never get here');
} catch (error: any) { } catch (error: any) {
const { response } = error; const { response } = error;
assert.strictEqual(response?.status, 404); assert.strictEqual(response?.status, 404);
assert.strictEqual(response?.data?.code, 404); assert.strictEqual(response?.data?.code, 404);
assert.strictEqual(response?.data?.name, "NotFound"); assert.strictEqual(response?.data?.name, 'NotFound');
} }
}); });
}); });

View File

@ -1,11 +1,11 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html // For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html
import assert from "assert"; import assert from 'assert';
import { app } from "../../../src/app"; import { app } from '../../../src/app';
describe("aliases service", () => { describe('aliases service', () => {
it("registered the service", () => { it('registered the service', () => {
const service = app.service("aliases"); const service = app.service('aliases');
assert.ok(service, "Registered the service"); assert.ok(service, 'Registered the service');
}); });
}); });

View File

@ -1,11 +1,11 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html // For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html
import assert from "assert"; import assert from 'assert';
import { app } from "../../../src/app"; import { app } from '../../../src/app';
describe("auth-oidc service", () => { describe('auth-oidc service', () => {
it("registered the service", () => { it('registered the service', () => {
const service = app.service("auth-oidc"); const service = app.service('auth-oidc');
assert.ok(service, "Registered the service"); assert.ok(service, 'Registered the service');
}); });
}); });

View File

@ -1,11 +1,11 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html // For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html
import assert from "assert"; import assert from 'assert';
import { app } from "../../../../src/app"; import { app } from '../../../../src/app';
describe("auth-oidc/callback service", () => { describe('auth-oidc/callback service', () => {
it("registered the service", () => { it('registered the service', () => {
const service = app.service("auth-oidc/callback"); const service = app.service('auth-oidc/callback');
assert.ok(service, "Registered the service"); assert.ok(service, 'Registered the service');
}); });
}); });

View File

@ -1,29 +1,29 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/validators.html // For more information about this file see https://dove.feathersjs.com/guides/cli/validators.html
import { Ajv, addFormats } from "@feathersjs/schema"; import { Ajv, addFormats } from '@feathersjs/schema';
import type { FormatsPluginOptions } from "@feathersjs/schema"; import type { FormatsPluginOptions } from '@feathersjs/schema';
const formats: FormatsPluginOptions = [ const formats: FormatsPluginOptions = [
"date-time", 'date-time',
"time", 'time',
"date", 'date',
"email", 'email',
"hostname", 'hostname',
"ipv4", 'ipv4',
"ipv6", 'ipv6',
"uri", 'uri',
"uri-reference", 'uri-reference',
"uuid", 'uuid',
"uri-template", 'uri-template',
"json-pointer", 'json-pointer',
"relative-json-pointer", 'relative-json-pointer',
"regex", 'regex',
]; ];
export const dataValidator: Ajv = addFormats(new Ajv({}), formats); export const dataValidator: Ajv = addFormats(new Ajv({}), formats);
export const queryValidator: Ajv = addFormats( export const queryValidator: Ajv = addFormats(
new Ajv({ new Ajv({
coerceTypes: true, coerceTypes: true,
}), }),
formats, formats
); );