first commit

This commit is contained in:
Sergo 2023-07-29 21:10:00 +03:00
commit 27efd26d6c
49 changed files with 5490 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.idea/
.vscode/
lib/

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
FROM node:18-alpine as dev
RUN apk add netcat-openbsd
RUN npm config set update-notifier false
WORKDIR /app
COPY . /app
COPY src ./
RUN npm ci --silent
RUN npm run compile
ENTRYPOINT npm run start
FROM node:18-alpine AS prod
RUN npm config set update-notifier false
WORKDIR /app
COPY --from=dev /app/package.json /app/package-lock.json /app/
COPY config /app/config
COPY --from=dev /app/lib /app/lib
RUN npm ci --only=production --silent
CMD ["npm", "start"]

93
app.ts Normal file
View File

@ -0,0 +1,93 @@
import { feathers } from '@feathersjs/feathers'
import express, {
rest,
json,
urlencoded,
cors,
serveStatic,
notFound,
errorHandler
} from '@feathersjs/express'
import configuration from '@feathersjs/configuration'
import socketio from '@feathersjs/socketio'
import session from 'express-session';
import cookieParser from 'cookie-parser';
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 { randomUUID } from 'crypto';
const app: Application = express(feathers())
// Load app configuration
app.configure(configuration())
app.use(cors())
app.use(json({
limit: '20mb'
}))
app.use(cookieParser());
app.use(session({
secret: randomUUID(),
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}));
// Propagate session to request.params in feathers services
app.use(function (req, _res, next) {
req.feathers = {
...req.feathers,
session: req.session
}
next()
});
app.use('/authme', (req, res) => {
//@ts-ignore
req.session.user = {
email: "her@va.mm"
}
res.send("done locally")
})
app.use(urlencoded({ extended: true }))
// Host the public folder
app.use('/', serveStatic(app.get('public')))
// Configure services and real-time functionality
app.configure(rest())
app.configure(
socketio({
cors: {
origin: app.get('origins')
}
})
)
app.configure(services)
app.configure(channels)
// Configure a middleware for 404s and the error handler
app.use(notFound())
app.use(errorHandler({ logger }))
// Register hooks that run on all service methods
app.hooks({
around: {
all: [logError]
},
before: {},
after: {},
error: {}
})
// Register application setup and teardown hooks here
app.hooks({
setup: [],
teardown: []
})
export { app }

38
channels.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,10 @@
{
"port": {
"__name": "PORT",
"__format": "number"
},
"host": "HOSTNAME",
"authentication": {
"secret": "FEATHERS_SECRET"
}
}

16
config/default.json Normal file
View File

@ -0,0 +1,16 @@
{
"host": "localhost",
"port": 3030,
"public": "./public/",
"origins": [
"http://localhost:3030"
],
"paginate": {
"default": 10,
"max": 50
},
"wildDuck": {
"url": "http://localhost",
"token": "aaaaa"
}
}

13
config/prod.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
clientUrl: process.env.CLIENT_URL,
oidc: {
gatewayUri: process.env.OIDC_GATEWAY_URI,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
redirectUris: process.env.OIDC_REDIRECT_URIS
},
wildDuck: {
url: process.env.WILDDUCK_URL,
token: process.env.WILDDUCK_TOKEN
}
};

3
config/test.json Normal file
View File

@ -0,0 +1,3 @@
{
"port": 8998
}

20
declarations.ts Normal file
View File

@ -0,0 +1,20 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/typescript.html
import { HookContext as FeathersHookContext, NextFunction } from '@feathersjs/feathers'
import { Application as FeathersApplication } from '@feathersjs/express'
type ApplicationConfiguration = any
export { NextFunction }
// The types for app.get(name) and app.set(name)
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Configuration extends ApplicationConfiguration {}
// A mapping of service names to types. Will be extended in service files.
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServiceTypes {}
// The application instance type that will be used everywhere else
export type Application = FeathersApplication<ServiceTypes, Configuration>
// The context for hook functions - can be typed with a service class
export type HookContext<S = any> = FeathersHookContext<Application, S>

88
deployment.yaml Normal file
View File

@ -0,0 +1,88 @@
---
apiVersion: codemowers.io/v1alpha1
kind: OIDCGWClient
metadata:
name: walias
spec:
uri: "https://walias-msergo.codemowers.ee/auth-oidc"
redirectUris:
- "https://walias-msergo.codemowers.ee/auth-oidc/callback"
grantTypes:
- "authorization_code"
- "refresh_token" # might be supported by some implementations
responseTypes:
- "code"
availableScopes:
- "openid"
- "profile"
- "offline_access"
tokenEndpointAuthMethod: "client_secret_basic"
pkce: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: walias
annotations:
kubernetes.io/ingress.class: shared
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
external-dns.alpha.kubernetes.io/target: traefik.codemowers.ee
spec:
rules:
- host: walias-msergo.codemowers.ee
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: walias
port:
number: 3030
tls:
- hosts:
- "*.codemowers.ee"
---
apiVersion: v1
kind: Service
metadata:
name: walias
spec:
type: ClusterIP
selector:
app: walias
ports:
- protocol: TCP
port: 3030
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: walias
labels:
app: walias
spec:
selector:
matchLabels:
app: walias
replicas: 1
template:
metadata:
labels:
app: walias
spec:
containers:
- name: walias
image: walias
ports:
- containerPort: 3030
env:
- name: CLIENT_URL
value: https://walias-msergo.codemowers.ee
- name: NODE_ENV
value: prod
envFrom:
- secretRef:
name: oidc-client-walias-owner-secrets

17
hooks/log-error.ts Normal file
View File

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

9
hooks/validate-auth.ts Normal file
View File

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

11
index.ts Normal file
View File

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

10
logger.ts Normal file
View File

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

3919
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

78
package.json Normal file
View File

@ -0,0 +1,78 @@
{
"name": "walias",
"description": "Aliases for Wild Duck",
"version": "1.0.0",
"homepage": "",
"private": false,
"keywords": [
"feathers"
],
"author": {
"url": "github.com/msergo"
},
"contributors": [],
"bugs": {},
"engines": {
"node": ">= 16.19.1"
},
"feathers": {
"language": "ts",
"packager": "npm",
"database": "other",
"framework": "express",
"transports": [
"rest",
"websockets"
],
"schema": false
},
"directories": {
"lib": "src",
"test": "test"
},
"main": "lib/index",
"scripts": {
"dev": "nodemon -x ts-node src/index.ts",
"compile": "shx rm -rf lib/ && tsc",
"start": "node lib/",
"prettier": "npx prettier \"**/*.ts\" --write",
"mocha": "cross-env NODE_ENV=test mocha test/ --require ts-node/register --recursive --extension .ts --exit",
"test": "npm run mocha",
"bundle:client": "npm run compile && npm pack --pack-destination ./public"
},
"dependencies": {
"@faker-js/faker": "^8.0.2",
"@feathersjs/adapter-commons": "^5.0.8",
"@feathersjs/authentication": "^5.0.8",
"@feathersjs/authentication-client": "^5.0.8",
"@feathersjs/configuration": "^5.0.8",
"@feathersjs/errors": "^5.0.8",
"@feathersjs/express": "^5.0.8",
"@feathersjs/feathers": "^5.0.8",
"@feathersjs/schema": "^5.0.8",
"@feathersjs/socketio": "^5.0.8",
"@feathersjs/transport-commons": "^5.0.8",
"axios": "^1.4.0",
"compression": "^1.7.4",
"config": "^3.3.9",
"cookie-parser": "^1.4.6",
"express-session": "^1.17.3",
"openid-client": "^5.4.3",
"winston": "^3.10.0"
},
"devDependencies": {
"@feathersjs/cli": "^5.0.8",
"@feathersjs/rest-client": "^5.0.8",
"@types/cookie-parser": "^1.4.3",
"@types/express-session": "^1.17.7",
"@types/mocha": "^10.0.1",
"@types/node": "^20.4.5",
"cross-env": "^7.0.3",
"mocha": "^10.2.0",
"nodemon": "^3.0.1",
"prettier": "^3.0.0",
"shx": "^0.3.4",
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
}
}

37
public/index.html Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>walias</title>
<meta name="description" content="Aliases for Wild Duck">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
body {
min-height: 100%;
display: flex;
align-items: center;
}
img.logo {
display: block;
margin: auto auto;
width: 30%;
max-width: 100%;
max-height: 100%;
}
</style>
</head>
<body>
<img class="logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjUwMCIgaGVpZ2h0PSIyNTAwIiB2aWV3Qm94PSIwIDAgMjU2IDI1NiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTEyOCA5LjEwMmM2NS42NjUgMCAxMTguODk4IDUzLjIzMyAxMTguODk4IDExOC44OTggMCA2NS42NjUtNTMuMjMzIDExOC44OTgtMTE4Ljg5OCAxMTguODk4QzYyLjMzNSAyNDYuODk4IDkuMTAyIDE5My42NjUgOS4xMDIgMTI4IDkuMTAyIDYyLjMzNSA2Mi4zMzUgOS4xMDIgMTI4IDkuMTAyTTEyOCAwQzU3LjQyMSAwIDAgNTcuNDIxIDAgMTI4YzAgNzAuNTc5IDU3LjQyMSAxMjggMTI4IDEyOCA3MC41NzkgMCAxMjgtNTcuNDIxIDEyOC0xMjhDMjU2IDU3LjQyMSAxOTguNTc5IDAgMTI4IDBtMjAuODMgMjUuNTI0Yy0xMC40My0xLjg5Ni0zNS42NTEgMzYuNDA5LTQzLjk5NCA1OS43MzQtLjYzNCAxLjc2OS0yLjA4NiA4LjI0OS0yLjA4NiA5Ljk1NSAwIDAgNi41MzEgMTQuMDU1IDguMzQzIDE3LjM1MS0zLjAzNC0xLjU4LTkuMzIzLTEzLjc1Ni05LjMyMy0xMy43NTYtMy4wMzQgNS43ODQtNS45NDIgMzIuMzQtNC45OTQgMzcuMjcxIDAgMCA2Ljc2MiAxMC4wNjIgOS4zODcgMTIuNTc4LTMuNjAzLTEuMjAxLTkuNjcxLTkuMzU1LTkuNjcxLTkuMzU1LTEuMTM4IDMuNTA4LS45MTYgMTAuODA3LS4zNzkgMTMuMjc0IDQuNTUxIDYuNjM3IDEwLjYxOSA3LjM5NiAxMC42MTkgNy4zOTZzLTYuNjM3IDY2LjE4MSAzLjQxMyA3MS4xMTFjNi4yNTgtMS4zMjcgNy43NzUtNzMuOTU2IDcuNzc1LTczLjk1NnM3LjU4NS41NjkgOS4yOTItMS4zMjdjMy44NTYtMi42NTUgMTIuODI2LTMwLjIyNCAxMi45NTgtMzQuMjAyIDAgMC0xMC40MSAxLjk1Mi0xNS40ODcgMy45MjQgMy44MjYtMy44IDE2LjA0OS02LjM1MiAxNi4wNDktNi4zNTIgMy4zMTUtMy45NzkgMTAuMjkxLTMxLjA0NyAxMC45OTQtMzkuMzkxLjE3Ni0yLjA5My41ODMtNC42NTcuMjY4LTguMzk4IDAgMC05Ljk0MSAyLjE3Ny0xMi4wMTQgMS40MjQgMi4xMDQtLjIzNyAxMi4yNjMtNC4xNCAxMi4yNjMtNC4xNCAxLjgwMS0xNi4yMTMgMi4zNTgtNDIuMDkxLTMuNDEzLTQzLjE0MXptLTM2LjM4IDE3MS42OTFjLS43OTUgMTkuNDk2LTEuMjk0IDI1LjAwNC0yLjExNSAyOS42MDEtLjM3OS44NTctLjc1OC45OTctMS4xMzgtLjA5NS0zLjQ3Ny0xNS45OTItMy4yMjQtMTM2LjQzOCAzNi40MDktMTkxLjI0MS0yMy4wNSA0Mi4wOTItMzMuNTM1IDEyMi44NjEtMzMuMTU2IDE2MS43MzV6IiBmaWxsPSIjMzMzIi8+PC9zdmc+" />
</body>
</html>

42
readme.md Normal file
View File

@ -0,0 +1,42 @@
# walias
> Aliases for Wild Duck
## About
This project uses [Feathers](http://feathersjs.com). An open source framework for building APIs and real-time applications.
## Getting Started
1. Make sure you have [NodeJS](https://nodejs.org/) and [npm](https://www.npmjs.com/) installed.
2. Install your dependencies
```
cd path/to/walias
npm install
```
3. Start your app
```
npm run compile # Compile TypeScript source
npm run migrate # Run migrations to set up the database
npm start
```
## Testing
Run `npm test` and all your tests in the `test/` directory will be run.
## Scaffolding
This app comes with a powerful command line interface for Feathers. Here are a few things it can do:
```
$ npx feathers help # Show all commands
$ npx feathers generate service # Generate a new Service
```
## Help
For more information on all the things you can do with Feathers visit [docs.feathersjs.com](http://docs.feathersjs.com).

View File

@ -0,0 +1,84 @@
import type { Params, ServiceInterface } from '@feathersjs/feathers'
import type { Application } from '../../declarations'
import wildDuckClient from '../../clients/wildduck.client'
import { faker } from '@faker-js/faker'
import { BadRequest } from '@feathersjs/errors'
interface Alias {
success: boolean,
id: string,
address: string,
main: boolean,
user: string,
tags: string[],
created: string,
}
interface GetAddressInfoResponse {
success: boolean,
results: Alias[]
}
interface CreateAddressResponse {
success: boolean,
id: string,
}
type AliasesData = any
type AliasesPatch = any
type AliasesQuery = any
export type { Alias as Aliases, AliasesData, AliasesPatch, AliasesQuery }
export interface AliasesServiceOptions {
app: Application
}
export interface AliasesParams extends Params<AliasesQuery> {
session?: any
}
export class AliasesService<ServiceParams extends AliasesParams = AliasesParams>
implements ServiceInterface<Alias, AliasesData, ServiceParams, AliasesPatch>
{
constructor(public options: AliasesServiceOptions) { }
async find(params: ServiceParams): Promise<Alias[]> {
const userId = await this.getUserIdByEmailAddress(params)
const { data: userAddressesResponse } = await wildDuckClient.get<GetAddressInfoResponse>(`/users/${userId}/addresses`)
return userAddressesResponse.results
}
async create(data: AliasesData, params: ServiceParams): Promise<Alias>
async create(data: AliasesData, params: ServiceParams): Promise<Alias | Alias[]> {
const userId = await this.getUserIdByEmailAddress(params)
const alias = `${faker.animal.crocodilia().replace(/\s/, '').slice(10)}-${faker.git.commitSha({ length: 5 })}`;
const createResult = await wildDuckClient.post<CreateAddressResponse>(`/users/${userId}/addresses`, {
address: alias
})
if (!createResult.data.success) {
throw new BadRequest('Failed to create alias')
}
const { data: userAddressesResponse } = await wildDuckClient.get<GetAddressInfoResponse>(`/users/${userId}/addresses`)
return userAddressesResponse.results
}
private async getUserIdByEmailAddress(params: ServiceParams): Promise<string> {
const emails = params.session?.user?.emails;
const addressInfoResponse = await Promise.any(emails.map((email: string) => wildDuckClient.get<Alias>(`addresses/resolve/${email}`)))
return addressInfoResponse.data.user
}
}
export const getOptions = (app: Application) => {
return { app }
}

View File

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

View File

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

View File

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

View File

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

View File

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

10
services/index.ts Normal file
View File

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

25
skaffold.yaml Normal file
View File

@ -0,0 +1,25 @@
apiVersion: skaffold/v4beta1
kind: Config
build:
artifacts:
- image: walias
manifests:
rawYaml:
- deployment.yaml
profiles:
- name: dev
activation:
- command: dev
build:
artifacts:
- image: walias
docker:
target: dev
sync:
manual:
- src: .
dest: /app/
deploy:
kubectl: {}

93
src/app.ts Normal file
View File

@ -0,0 +1,93 @@
import { feathers } from '@feathersjs/feathers'
import express, {
rest,
json,
urlencoded,
cors,
serveStatic,
notFound,
errorHandler
} from '@feathersjs/express'
import configuration from '@feathersjs/configuration'
import socketio from '@feathersjs/socketio'
import session from 'express-session';
import cookieParser from 'cookie-parser';
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 { randomUUID } from 'crypto';
const app: Application = express(feathers())
// Load app configuration
app.configure(configuration())
app.use(cors())
app.use(json({
limit: '20mb'
}))
app.use(cookieParser());
app.use(session({
secret: randomUUID(),
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}));
// Propagate session to request.params in feathers services
app.use(function (req, _res, next) {
req.feathers = {
...req.feathers,
session: req.session
}
next()
});
app.use('/authme', (req, res) => {
//@ts-ignore
req.session.user = {
email: "her@va.mm"
}
res.send("done locally")
})
app.use(urlencoded({ extended: true }))
// Host the public folder
app.use('/', serveStatic(app.get('public')))
// Configure services and real-time functionality
app.configure(rest())
app.configure(
socketio({
cors: {
origin: app.get('origins')
}
})
)
app.configure(services)
app.configure(channels)
// Configure a middleware for 404s and the error handler
app.use(notFound())
app.use(errorHandler({ logger }))
// Register hooks that run on all service methods
app.hooks({
around: {
all: [logError]
},
before: {},
after: {},
error: {}
})
// Register application setup and teardown hooks here
app.hooks({
setup: [],
teardown: []
})
export { app }

38
src/channels.ts Normal file
View File

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

View File

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

20
src/declarations.ts Normal file
View File

@ -0,0 +1,20 @@
// For more information about this file see https://dove.feathersjs.com/guides/cli/typescript.html
import { HookContext as FeathersHookContext, NextFunction } from '@feathersjs/feathers'
import { Application as FeathersApplication } from '@feathersjs/express'
type ApplicationConfiguration = any
export { NextFunction }
// The types for app.get(name) and app.set(name)
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Configuration extends ApplicationConfiguration {}
// A mapping of service names to types. Will be extended in service files.
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServiceTypes {}
// The application instance type that will be used everywhere else
export type Application = FeathersApplication<ServiceTypes, Configuration>
// The context for hook functions - can be typed with a service class
export type HookContext<S = any> = FeathersHookContext<Application, S>

17
src/hooks/log-error.ts Normal file
View File

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

View File

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

11
src/index.ts Normal file
View File

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

10
src/logger.ts Normal file
View File

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

View File

@ -0,0 +1,92 @@
import type { Params, ServiceInterface } from '@feathersjs/feathers'
import type { Application } from '../../declarations'
import wildDuckClient from '../../clients/wildduck.client'
import { faker } from '@faker-js/faker'
import { BadRequest } from '@feathersjs/errors'
import config from 'config'
interface Alias {
success: boolean,
id: string,
address: string,
main: boolean,
user: string,
tags: string[],
created: string,
}
interface GetAddressInfoResponse {
success: boolean,
results: Alias[]
}
interface CreateAddressResponse {
success: boolean,
id: string,
}
type AliasesData = any
type AliasesPatch = any
type AliasesQuery = any
export type { Alias as Aliases, AliasesData, AliasesPatch, AliasesQuery }
export interface AliasesServiceOptions {
app: Application
}
export interface AliasesParams extends Params<AliasesQuery> {
session?: any
}
export class AliasesService<ServiceParams extends AliasesParams = AliasesParams>
implements ServiceInterface<Alias, AliasesData, ServiceParams, AliasesPatch>
{
constructor(public options: AliasesServiceOptions) { }
async find(params: ServiceParams): Promise<Alias[]> {
const userId = await this.getUserIdByEmailAddress(params)
const { data: userAddressesResponse } = await wildDuckClient.get<GetAddressInfoResponse>(`/users/${userId}/addresses`)
return userAddressesResponse.results
}
async create(data: AliasesData, params: ServiceParams): Promise<Alias>
async create(data: AliasesData, params: ServiceParams): Promise<Alias | Alias[]> {
const userId = await this.getUserIdByEmailAddress(params)
const aliasFirstPart = faker.animal.crocodilia()
.replace(/\D/, '')
.replace(/\s/, '')
.slice(10);
const aliasSecondPart = faker.git.commitSha({ length: 5 });
const alias = `${aliasFirstPart}-${aliasSecondPart}@${config.get('wildDuck.domain')}`;
// const alias = `${faker.animal.crocodilia().replace(/\s/, '').slice(10)}-${faker.git.commitSha({ length: 5 })}`;
const createResult = await wildDuckClient.post<CreateAddressResponse>(`/users/${userId}/addresses`, {
address: alias
})
if (!createResult.data.success) {
throw new BadRequest('Failed to create alias')
}
const { data: userAddressesResponse } = await wildDuckClient.get<GetAddressInfoResponse>(`/users/${userId}/addresses`)
return userAddressesResponse.results
}
private async getUserIdByEmailAddress(params: ServiceParams): Promise<string> {
const emails = params.session?.user?.emails;
const addressInfoResponse = await Promise.any(emails.map((email: string) => wildDuckClient.get<Alias>(`addresses/resolve/${email}`)))
return addressInfoResponse.data.user
}
}
export const getOptions = (app: Application) => {
return { app }
}

View File

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

View File

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

View File

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

View File

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

View File

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

10
src/services/index.ts Normal file
View File

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

29
src/validators.ts Normal file
View File

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

40
test/app.test.ts Normal file
View File

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

View File

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

View File

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

View File

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

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"ts-node": {
"files": true
},
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
],
"module": "commonjs",
"outDir": "./lib",
"rootDir": "./src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"sourceMap": true
},
"include": [
"src"
],
"exclude": [
"test"
]
}

29
validators.ts Normal file
View File

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