This commit is contained in:
2024-04-21 06:37:19 +00:00
parent 2410ef056c
commit 59540510b7
97 changed files with 1930 additions and 1029 deletions

View File

@@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

5
monolithic-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.env
.generated
graphql.schema.json
dist

View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -0,0 +1,8 @@
# Namespacing
- Database and Graphql is namespaced by prefixed camelcase
- Typescript is namespaced by `namespace` keyword
# Adding to the enumeration types
- Update graphql enum
- Update services/core/enumerations namespaced types

View File

@@ -0,0 +1,73 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View File

@@ -0,0 +1,17 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
overwrite: true,
schema: "../graphql",
generates: {
"src/.generated/graphql.ts": {
plugins: ["typescript", "typescript-resolvers"]
},
"./graphql.schema.json": {
plugins: ["introspection"]
}
}
};
export default config;

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": ["@nestjs/graphql"]
}
}

13730
monolithic-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
{
"name": "@homelab-personal-cloud/core",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "graphql-codegen --config codegen.ts",
"migrate": "npx prisma db push",
"migrate:post": "nest start --entryFile prisma-post-migrations"
},
"dependencies": {
"@apollo/server": "^4.10.2",
"@nestjs/apollo": "^12.1.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/graphql": "^12.1.1",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.12.1",
"bcrypt": "^5.1.1",
"graphql": "^16.8.1",
"joi": "17.6.4",
"pug": "^3.0.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/introspection": "4.0.3",
"@graphql-codegen/typescript": "^4.0.6",
"@graphql-codegen/typescript-resolvers": "^4.0.6",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.12.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,205 @@
-- CreateTable
CREATE TABLE "SystemSetting" (
"hashKey" TEXT NOT NULL PRIMARY KEY,
"hashValueType" TEXT NOT NULL,
"hashValue" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "SystemPostMigration" (
"name" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "AuthRealm" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "AuthOauth2Client" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"clientId" TEXT NOT NULL,
"clientSecret" TEXT,
"consentRequired" BOOLEAN NOT NULL DEFAULT false,
"authorizationCodeFlowEnabled" BOOLEAN NOT NULL DEFAULT false,
"resourceOwnerPasswordCredentialsFlowEnabled" BOOLEAN NOT NULL DEFAULT false,
"clientCredentialsFlowEnabled" BOOLEAN NOT NULL DEFAULT false,
"idTokenEnabled" BOOLEAN NOT NULL DEFAULT false,
"refreshTokenEnabled" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "AuthOauth2Client_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthOauth2Scope" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"scope" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "AuthOauth2ClientToAuthOauth2Scope" (
"clientId" INTEGER NOT NULL,
"scopeId" INTEGER NOT NULL,
PRIMARY KEY ("clientId", "scopeId"),
CONSTRAINT "AuthOauth2ClientToAuthOauth2Scope_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "AuthOauth2Client" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "AuthOauth2ClientToAuthOauth2Scope_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "AuthOauth2Scope" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthOauth2ScopeToIdentityProfileAttributeName" (
"scopeId" INTEGER NOT NULL,
"claimName" TEXT NOT NULL,
"attributeId" INTEGER NOT NULL,
PRIMARY KEY ("scopeId", "attributeId"),
CONSTRAINT "AuthOauth2ScopeToIdentityProfileAttributeName_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "AuthOauth2Scope" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "AuthOauth2ScopeToIdentityProfileAttributeName_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "IdentityProfileAttributeName" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthRole" (
"realmId" INTEGER NOT NULL,
"roleName" TEXT NOT NULL,
PRIMARY KEY ("realmId", "roleName"),
CONSTRAINT "AuthRole_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthAccessAttempt" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"userAgent" TEXT NOT NULL,
"requestPath" TEXT NOT NULL,
"valid" BOOLEAN NOT NULL,
"attemptedOn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "EnumIdentityGroupRole" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "IdentityGroup" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"role" TEXT NOT NULL,
"name" TEXT,
CONSTRAINT "IdentityGroup_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityGroup_role_fkey" FOREIGN KEY ("role") REFERENCES "EnumIdentityGroupRole" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityGroupToIdentityUser" (
"groupId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"userIsGroupAdmin" BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY ("groupId", "userId"),
CONSTRAINT "IdentityGroupToIdentityUser_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityGroupToIdentityUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUser" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"externalId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"realmId" INTEGER NOT NULL,
CONSTRAINT "IdentityUser_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityProfileAttributeName" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "IdentityProfileAttributeName_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityProfileNonNormalized" (
"userId" INTEGER NOT NULL,
"attributeNameId" INTEGER NOT NULL,
"attributeValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "attributeNameId"),
CONSTRAINT "IdentityProfileNonNormalized_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityProfileNonNormalized_attributeNameId_fkey" FOREIGN KEY ("attributeNameId") REFERENCES "IdentityProfileAttributeName" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUserEmails" (
"email" TEXT NOT NULL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"default" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "IdentityUserEmails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EnumIdentityAuthDeviceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "IdentityAuthDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"deviceType" TEXT NOT NULL,
"attributes" TEXT NOT NULL,
"preferred" BOOLEAN NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IdentityAuthDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityAuthDevice_deviceType_fkey" FOREIGN KEY ("deviceType") REFERENCES "EnumIdentityAuthDeviceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EnumCloudDavResourceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "CloudDavResource" (
"id" TEXT NOT NULL PRIMARY KEY,
"identityGroupId" INTEGER NOT NULL,
"resourceType" TEXT NOT NULL,
"attributes" TEXT NOT NULL,
CONSTRAINT "CloudDavResource_identityGroupId_fkey" FOREIGN KEY ("identityGroupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "CloudDavResource_resourceType_fkey" FOREIGN KEY ("resourceType") REFERENCES "EnumCloudDavResourceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthRealm_name_key" ON "AuthRealm"("name");
-- CreateIndex
CREATE UNIQUE INDEX "AuthOauth2Client_realmId_clientId_key" ON "AuthOauth2Client"("realmId", "clientId");
-- CreateIndex
CREATE UNIQUE INDEX "AuthOauth2Scope_realmId_scope_key" ON "AuthOauth2Scope"("realmId", "scope");
-- CreateIndex
CREATE UNIQUE INDEX "AuthOauth2ScopeToIdentityProfileAttributeName_scopeId_claimName_key" ON "AuthOauth2ScopeToIdentityProfileAttributeName"("scopeId", "claimName");
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_externalId_key" ON "IdentityUser"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_username_key" ON "IdentityUser"("username");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_idx" ON "IdentityAuthDevice"("userId");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_deviceType_idx" ON "IdentityAuthDevice"("userId", "deviceType");
-- CreateIndex
CREATE INDEX "CloudDavResource_identityGroupId_idx" ON "CloudDavResource"("identityGroupId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@@ -0,0 +1,242 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../../data/core.db"
}
//
// Namespace: System
//
model SystemSetting {
hashKey String @id
hashValueType String
hashValue String
}
model SystemPostMigration {
name String @id
createdAt DateTime @default(now())
}
//
// Namespace: Auth
//
model AuthRealm {
id Int @id @default(autoincrement())
name String @unique
createdAt DateTime @default(now())
oauth2Clients AuthOauth2Client[]
groups IdentityGroup[]
users IdentityUser[]
profileAttributeNames IdentityProfileAttributeName[]
roles AuthRole[]
}
model AuthOauth2Client {
id Int @id @default(autoincrement())
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
clientId String
clientSecret String?
consentRequired Boolean @default(false)
authorizationCodeFlowEnabled Boolean @default(false)
resourceOwnerPasswordCredentialsFlowEnabled Boolean @default(false)
clientCredentialsFlowEnabled Boolean @default(false)
idTokenEnabled Boolean @default(false)
refreshTokenEnabled Boolean @default(false)
scopeMappings AuthOauth2ClientToAuthOauth2Scope[]
@@unique([realmId, clientId])
}
model AuthOauth2Scope {
id Int @id @default(autoincrement())
realmId Int
scope String
profileAttributeMappings AuthOauth2ScopeToIdentityProfileAttributeName[]
clientMappings AuthOauth2ClientToAuthOauth2Scope[]
@@unique([realmId, scope])
}
model AuthOauth2ClientToAuthOauth2Scope {
clientId Int
oauth2Client AuthOauth2Client @relation(fields: [clientId], references: [id])
scopeId Int
scope AuthOauth2Scope @relation(fields: [scopeId], references: [id])
@@id([clientId, scopeId])
}
model AuthOauth2ScopeToIdentityProfileAttributeName {
scopeId Int
scope AuthOauth2Scope @relation(fields: [scopeId], references: [id])
claimName String
attributeId Int
attributes IdentityProfileAttributeName @relation(fields: [attributeId], references: [id])
@@id([scopeId, attributeId])
@@unique([scopeId, claimName])
}
model AuthRole {
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
roleName String
@@id([realmId, roleName])
}
model AuthAccessAttempt {
id String @id @default(uuid())
username String
ip String
userAgent String
requestPath String
valid Boolean
attemptedOn DateTime @default(now())
}
//
// Namespace: Identity
//
model EnumIdentityGroupRole {
enumValue String @id
groups IdentityGroup[]
}
model IdentityGroup {
id Int @id @default(autoincrement())
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
role String
roleRelation EnumIdentityGroupRole @relation(fields: [role], references: [enumValue])
name String?
users IdentityGroupToIdentityUser[]
davResources CloudDavResource[]
}
model IdentityGroupToIdentityUser {
groupId Int
group IdentityGroup @relation(fields: [groupId], references: [id])
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
userIsGroupAdmin Boolean @default(false)
@@id([groupId, userId])
}
model IdentityUser {
id Int @id @default(autoincrement())
externalId String @unique @default(uuid())
username String @unique
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
groups IdentityGroupToIdentityUser[]
profileHashMapPairs IdentityProfileNonNormalized[]
emails IdentityUserEmails[]
authDevices IdentityAuthDevice[]
}
model IdentityProfileAttributeName {
id Int @id @default(autoincrement())
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
name String
attributeUses IdentityProfileNonNormalized[]
scopeMappings AuthOauth2ScopeToIdentityProfileAttributeName[]
}
model IdentityProfileNonNormalized {
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
attributeNameId Int
attributeName IdentityProfileAttributeName @relation(fields: [attributeNameId], references: [id])
attributeValue String
createdAt DateTime @default(now())
@@id([userId, attributeNameId])
}
model IdentityUserEmails {
email String @id
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
verified Boolean @default(false)
default Boolean @default(false)
}
model EnumIdentityAuthDeviceType {
enumValue String @id
authDevices IdentityAuthDevice[]
}
model IdentityAuthDevice {
id String @id @default(uuid())
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
deviceType String
deviceTypeRelation EnumIdentityAuthDeviceType @relation(fields: [deviceType], references: [enumValue])
attributes String
preferred Boolean
createdAt DateTime @default(now())
@@index([userId])
@@index([userId, deviceType])
}
//
// Namespace: cloud-dav
//
model EnumCloudDavResourceType {
enumValue String @id
davResources CloudDavResource[]
}
model CloudDavResource {
id String @id @default(uuid())
identityGroupId Int
IdentityGroup IdentityGroup @relation(fields: [identityGroupId], references: [id])
resourceType String
resourceTypeRelation EnumCloudDavResourceType @relation(fields: [resourceType], references: [enumValue])
attributes String
@@index([identityGroupId])
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { CacheModule } from './cache/cache.module';
import { ConfigModule } from './config/config.module';
import { PersistenceModule } from './persistence/persistence.module';
import { GraphqlServerModule } from './graphql-server/graphql-server.module';
import { HttpModule } from './http/http.module';
@Module({
imports: [
HttpModule,
CacheModule,
ConfigModule,
GraphqlServerModule,
PersistenceModule,
],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { CacheDriver } from './cache.symbols';
import { InMemoryDriver } from './in-memory.driver';
import { CacheService } from './cache.service';
@Module({
providers: [
{
provide: CacheDriver,
useValue: new InMemoryDriver(),
},
CacheService,
],
exports: [CacheService],
})
export class CacheModule {}

View File

@@ -0,0 +1,32 @@
import { Inject, Injectable } from '@nestjs/common';
import { CacheDriver } from './cache.symbols';
@Injectable()
export class CacheService {
constructor(
@Inject(CacheDriver)
private readonly cacheDriver: CacheDriver,
) {}
async get(key: string): Promise<string | null> {
return this.cacheDriver.get(key);
}
async set(key: string, val: string, ttl?: number): Promise<void> {
await this.cacheDriver.set(key, val);
if (ttl) {
await this.cacheDriver.expire(key, ttl);
}
}
async exists(key: string): Promise<boolean> {
return await this.cacheDriver.exists(key) > 0;
}
async del(key: string): Promise<void> {
await this.cacheDriver.del(key);
}
}

View File

@@ -0,0 +1,8 @@
export interface CacheDriver {
get(key: string): Promise<string>;
set(key: string, val: string): Promise<'OK'>;
exists(...keys: string[]): Promise<number>;
expire(key: string, seconds: number): Promise<number>;
del(key: string): Promise<number>;
}
export const CacheDriver = Symbol.for('CACHE_DRIVER');

View File

@@ -0,0 +1,42 @@
import { DateUtil } from '../utils';
import { CacheDriver } from './cache.symbols';
export class InMemoryDriver implements CacheDriver {
private readonly cache: Record<string, { val: string, ttl?: DateUtil }> = {};
get(key: string): Promise<string> {
if (!this.cache[key] || !this.cache[key].ttl?.isInTheFuture()) {
return Promise.resolve(null);
}
return Promise.resolve(this.cache[key].val);
}
set(key: string, val: string): Promise<'OK'> {
this.cache[key] = { val };
return Promise.resolve('OK');
}
exists(...keys: string[]): Promise<number> {
return Promise.resolve(
keys.reduce((acc, k) => {
if (k in this.cache) {
return acc + 1;
}
return acc;
}, 0)
);
}
expire(key: string, seconds: number): Promise<number> {
const dateUtil = DateUtil.fromDate(new Date()).addNSeconds(seconds);
this.cache[key].ttl = dateUtil;
return Promise.resolve(1);
}
del(key: string): Promise<number> {
delete this.cache[key];
return Promise.resolve(1);
}
}

View File

@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { PersistenceModule } from '../persistence/persistence.module';
import { SystemSettingsDao } from '../persistence/system-settings.dao';
import { LoadedConfig } from './config.symbol';
@Module({
imports: [
PersistenceModule,
],
providers: [
// {
// provide: LoadedConfig,
// useFactory: async () => {
// const prismaService = new PrismaService();
// await prismaService.onModuleInit();
// const sysSettingsService = new SystemSettingsDao(prismaService);
// const { valueMap } = await sysSettingsService.getSettings();
// await prismaService.onModuleDestroy();
// return valueMap;
// },
// },
{
provide: LoadedConfig,
useFactory: async (sysSettingsService: SystemSettingsDao) => {
const { valueMap } = await sysSettingsService.getSettings();
return valueMap;
},
inject: [SystemSettingsDao],
},
ConfigService
],
exports: [ConfigService],
})
export class ConfigModule {}

View File

@@ -0,0 +1,28 @@
import { Global, Inject, Injectable } from '@nestjs/common';
import { configValidator} from './config.validator';
import { SystemSettings } from '../domain/system-settings.types';
import { LoadedConfig } from './config.symbol';
@Injectable()
export class ConfigService {
private readonly _config: SystemSettings.Config;
constructor(
@Inject(LoadedConfig)
loadedConfig: LoadedConfig,
) {
const { value: config, error } = configValidator.validate(loadedConfig, { abortEarly: false, allowUnknown: false });
if (error) {
throw error;
}
this._config = config;
}
get<K extends keyof SystemSettings.Config>(key: K): SystemSettings.Config[K] {
return this._config[key];
}
}

View File

@@ -0,0 +1,2 @@
export type LoadedConfig = Record<string, string>;
export const LoadedConfig = Symbol.for('LOADED_CONFIG');

View File

@@ -0,0 +1,15 @@
import * as Joi from 'joi';
import { SystemSettings } from '../domain/system-settings.types';
export const configValidator: Joi.ObjectSchema<SystemSettings.Config> = Joi.object<SystemSettings.Config, true>({
[SystemSettings.Auth.AccessAttempts.CheckForwardedFor]: Joi.boolean().required(),
[SystemSettings.Auth.AccessAttempts.MaxAttempts]: Joi.number().required(),
[SystemSettings.Auth.AccessAttempts.Timeout]: Joi.number().required(),
[SystemSettings.Auth.Oauth2.Enabled]: Joi.boolean().required(),
[SystemSettings.Auth.Oauth2.EncryptionSecret]: Joi.string().required(),
[SystemSettings.Auth.TokenManagement.SigningSecret]: Joi.string().required(),
[SystemSettings.Graphql.Debug]: Joi.boolean().required(),
[SystemSettings.Graphql.IntrospectionEnabled]: Joi.boolean().required(),
[SystemSettings.Graphql.PlaygroundEnabled]: Joi.boolean().required(),
});

View File

@@ -0,0 +1,68 @@
export namespace Authentication {
export namespace AccessAttempt {
export interface CreateDto {
username: string;
ip: string;
userAgent: string;
requestPath: string;
valid: boolean;
}
}
export namespace Login {
export enum ResponseStatus {
Success = 'success',
TwoFactorRequired = '2fa',
Failure = 'failure',
Timedout = 'timedout',
DeviceLocked = 'locked',
}
export interface State {
jwi: string;
exp: number;
tfa: boolean;
}
export interface RequestContext {
isComplete: boolean;
rawState?: Object;
state?: State;
username?: string;
isLocked?: boolean;
}
}
export namespace Oauth2 {
export enum TokenClaims {
Issuer = 'iss',
Expiry = 'exp',
TokenId = 'jwi',
IssuedAt = 'iat',
}
export enum AuthorizationGrant {
AuthorizationCode = 'authorization_code',
Implicit = 'implicit',
ResourceOwnerPasswordCredentials = 'resource_owner_password_credentials',
ClientCredentials = 'client_credentials',
}
export enum GrantType {
RefreshToken = 'refresh_token',
Password = 'password',
}
export enum ResponseType {
Code = 'code',
Token = 'token',
}
export enum Error {
InvalidRequest = 'invalid_request',
UnauthorizedClient = 'unauthorized_client',
AccessDenied = 'access_denied',
UnsupportedResponseType = 'unsupported_response_type',
InvalidScope = 'invalid_scope',
ServerError = 'server_error',
TemporarilyUnavailable = 'temporarily_unavailable',
}
}
}

View File

@@ -0,0 +1,9 @@
export namespace CloudDav {
export namespace Resource {
export enum Type {
Contact = 'contact'
}
}
}

View File

@@ -0,0 +1,39 @@
export namespace Identity {
export interface AuthDevice {
readonly urn: string,
readonly userId: number,
readonly deviceType: AuthDevice.Type,
readonly preferred: boolean;
readonly createdAt: Date,
}
export namespace AuthDevice {
export enum Type {
Password = 'password',
ApplicationPassword = 'application_password',
}
export enum PasswordHashKey {
Expiry = 'expiry',
PasswordHashString = 'password_hash_string',
Locked = 'locked',
}
export interface PasswordAttributes {
readonly expiry: Date;
readonly passwordHashString: string;
readonly locked: boolean;
}
export type Password = AuthDevice | PasswordAttributes;
}
export namespace Group {
export enum Role {
SystemAdmin = 'SYSTEM_ADMIN',
RealmAdmin = 'REALM_ADMIN',
Standard = 'STANDARD',
}
}
}

View File

@@ -0,0 +1,18 @@
export abstract class Serializable {
toJson(): string {
return JSON.stringify(this);
}
static fromJson(): ThisType<Serializable> {
throw new Error('Method not implemented! Use derived class');
}
protected static safeJsonParse(str: string): [Error, null] | [null, Object] {
try {
return [null, JSON.parse(str)];
} catch (err) {
return [err, null];
}
}
}

View File

@@ -0,0 +1,43 @@
export namespace SystemSettings {
export interface Config {
[Auth.AccessAttempts.CheckForwardedFor]: boolean;
[Auth.AccessAttempts.MaxAttempts]: number;
[Auth.AccessAttempts.Timeout]: number;
[Auth.Oauth2.Enabled]: boolean;
[Auth.Oauth2.EncryptionSecret]: string;
[Auth.TokenManagement.SigningSecret]: string;
[Graphql.Debug]: boolean;
[Graphql.IntrospectionEnabled]: boolean;
[Graphql.PlaygroundEnabled]: boolean;
}
export namespace Auth {
export enum AccessAttempts {
MaxAttempts = 'auth.attempts.max',
Timeout = 'auth.attempts.timeout-ms',
CheckForwardedFor = 'auth.attempts.check-forwarded-for-header'
}
export enum Oauth2 {
Enabled = 'auth.oauth2.enabled',
EncryptionSecret = 'auth.oauth2.encryption_secret',
}
export enum TokenManagement {
SigningSecret = 'auth.token-management.signing_secret',
}
}
export enum Graphql {
Debug = 'graphql.debug.enabled',
IntrospectionEnabled = 'graphql.introspection.enabled',
PlaygroundEnabled = 'graphql.playground.enabled',
}
export namespace Dav {
export enum Contacts {
Enabled = 'dav.contacts.enabled',
}
}
}

View File

@@ -0,0 +1,31 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ConfigService } from '../config/config.service';
import { SystemSettingsResolver } from './system-settings.resolver';
import { ConfigModule } from '../config/config.module';
import { SystemSettings } from '../domain/system-settings.types';
import { PersistenceModule } from '../persistence/persistence.module';
@Module({
imports: [
ConfigModule,
PersistenceModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: async (configService: ConfigService) => ({
debug: await configService.get(SystemSettings.Graphql.Debug),
playground: await configService.get(SystemSettings.Graphql.PlaygroundEnabled),
introspection: await configService.get(SystemSettings.Graphql.IntrospectionEnabled),
typePaths: ['../graphql/**/*.graphql'],
}),
inject: [ConfigService],
imports: [ConfigModule],
})
],
providers: [
SystemSettingsResolver,
]
})
export class GraphqlServerModule {}

View File

@@ -0,0 +1,79 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Logger } from '@nestjs/common';
import { SystemSettingsQueryOutput, SystemSettingsQueryOutputError, UpdateSystemSettingInput, UpdateSystemSettingOutput, UpdateSystemSettingOutputError, SystemSettingHashValueTypeEnum } from '../.generated/graphql';
import { SystemSettingsDao } from '../persistence/system-settings.dao';
const getUrn = (hashKey: string): string => `urn:system-settings:${hashKey}`;
const parseUrn = (urn: string): string => urn.split(':').pop();
const t2g: Record<'string' | 'boolean' | 'number', SystemSettingHashValueTypeEnum> = {
string: SystemSettingHashValueTypeEnum.String,
boolean: SystemSettingHashValueTypeEnum.Boolean,
number: SystemSettingHashValueTypeEnum.Number,
}
const g2t: Record<SystemSettingHashValueTypeEnum, 'string' | 'boolean' | 'number'> = {
[SystemSettingHashValueTypeEnum.String]: 'string',
[SystemSettingHashValueTypeEnum.Boolean]: 'boolean',
[SystemSettingHashValueTypeEnum.Number]: 'number',
}
@Resolver('SystemSettings')
export class SystemSettingsResolver {
private readonly logger: Logger = new Logger(SystemSettingsResolver.name);
constructor(
private readonly systemSettingsDao: SystemSettingsDao,
) {}
@Query()
async systemSettings(): Promise<SystemSettingsQueryOutput> {
try {
const { valueMap, typeMap } = await this.systemSettingsDao.getSettings();
return {
data: Object.entries(valueMap).map(([hashKey, hashValue]) => ({
urn: getUrn(hashKey),
hashKey,
hashValue: hashValue.toString(),
hashValueType: t2g[typeMap[hashKey]],
})),
}
} catch (e) {
this.logger.error(e.message);
return {
error: SystemSettingsQueryOutputError.Unknown,
}
}
}
@Mutation()
async updateSystemSetting(
@Args('input') { urn, hashValue, hashValueType }: UpdateSystemSettingInput
): Promise<UpdateSystemSettingOutput> {
try {
const hashKey = parseUrn(urn);
const exists = await this.systemSettingsDao.hashKeyExists(hashKey);
if (!exists) {
return {
error: UpdateSystemSettingOutputError.NotFound,
}
}
await this.systemSettingsDao.setSettingWithType(hashKey, g2t[hashValueType], hashValue);
return {};
} catch (e) {
this.logger.error(e.message);
return {
error: UpdateSystemSettingOutputError.Unknown,
}
}
}
}

View File

@@ -0,0 +1,7 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class TooManyRequestsException extends HttpException {
constructor() {
super('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { LoginModule } from './mvc/login/login.module';
@Module({
imports: [LoginModule],
})
export class HttpModule {}

View File

@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { ConfigService } from '../../../config/config.service';
import { SystemSettings } from '../../../domain/system-settings.types';
import { AuthAccessAttemptDao } from '../../../persistence/auth-access-attempt.dao';
import { CacheService } from '../../../cache/cache.service';
export enum AccessAttemptError {
}
interface RequestData {
userAgent: string;
requestPath: string;
ip: string;
ipAddressKey: string;
usernameKey: string;
}
@Injectable()
export class AccessAttemptService {
private readonly maxAttemptsAllowed: number;
private readonly lockTimeout: number;
constructor(
private readonly cacheService: CacheService,
private readonly accessAttemptService: AuthAccessAttemptDao,
configService: ConfigService,
) {
this.maxAttemptsAllowed = configService.get(SystemSettings.Auth.AccessAttempts.MaxAttempts);
this.lockTimeout = configService.get(SystemSettings.Auth.AccessAttempts.Timeout);
}
async checkIfLocked(request: Request, username: string): Promise<boolean> {
const { ipAddressKey, usernameKey } = this.parseRequestData(request, username);
const ipAttempts = +await this.cacheService.get(ipAddressKey) ?? 0;
const usernameAttempts = +await this.cacheService.get(usernameKey) ?? 0;
if (ipAttempts >= this.maxAttemptsAllowed || usernameAttempts >= this.maxAttemptsAllowed) {
await this.cacheService.set(ipAddressKey, (ipAttempts + 1).toString(), this.lockTimeout);
await this.cacheService.set(usernameKey, (usernameAttempts + 1).toString(), this.lockTimeout);
return true;
}
return false;
}
async recordAttempt(request: Request, username: string, loginSucceeded: boolean): Promise<void> {
const isLocked = await this.checkIfLocked(request, username);
if (isLocked) {
return;
}
const {
userAgent,
requestPath,
ip,
ipAddressKey,
usernameKey,
} = this.parseRequestData(request, username);
await this.accessAttemptService.createAttempt({
userAgent,
username,
ip,
requestPath,
valid: loginSucceeded,
});
if (loginSucceeded) {
await this.cacheService.del(ipAddressKey);
await this.cacheService.del(usernameKey);
}
}
private parseRequestData(request: Request, username: string): RequestData {
const userAgent = request.headers['user-agent'];
const requestPath = request.path;
const ip = request.ip;
const ipAddressKey = `access-attempt:ip:${ip}`;
const usernameKey = `access-attempt:username:${username}`;
return {
userAgent,
requestPath,
ip,
ipAddressKey,
usernameKey,
}
}
}

View File

@@ -0,0 +1,42 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import * as Joi from 'joi';
import { DateUtil } from '../../../utils';
import { Authentication } from '../../../domain/authentication.types';
export const Context = createParamDecorator(
(data: unknown, ctx: ExecutionContext): Authentication.Login.RequestContext => {
const request = ctx.switchToHttp().getRequest();
const loginContext: Authentication.Login.RequestContext = request?.context;
if (!loginContext.username) {
return loginContext;
}
const state = loginContext?.rawState;
if (!state) {
return loginContext;
}
const { error, value } = Joi.object<Authentication.Login.State, true>({
jwi: Joi.string().required(),
exp: Joi.number().required(),
tfa: Joi.boolean().required(),
}).validate(state, { allowUnknown: false });
if (error) {
return loginContext;
}
if (DateUtil.fromSecondsSinceEpoch(value?.exp).isInThePast()) {
return loginContext;
}
return {
...loginContext,
isComplete: true,
state: value,
};
},
);

View File

@@ -0,0 +1,39 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { TokenManagementService } from '../../../token-management/token-management.service';
import { AccessAttemptService } from './access-attempt.service';
import { Authentication } from '../../../domain/authentication.types';
@Injectable()
export class LoginContextInterceptor implements NestInterceptor {
constructor(
private readonly tokenService: TokenManagementService,
private readonly accessAttemptService: AccessAttemptService,
) {}
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const loginContext: Authentication.Login.RequestContext = { isComplete: false };
if (!request?.body?.state) {
return next.handle();
}
const receipt = this.tokenService.parseToken(request.body.state);
if (receipt.isToken && receipt.signatureValid) {
loginContext.rawState = receipt.payload;
}
loginContext.username = request?.body?.username;
if (loginContext.username) {
loginContext.isLocked = await this.accessAttemptService.checkIfLocked(request, loginContext.username);
}
request.context = loginContext;
return next.handle();
}
}

View File

@@ -0,0 +1,130 @@
import { BadRequestException, Controller, Get, Param, Post, Query, Render, Res, UseInterceptors } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Authentication } from '../../../domain/authentication.types';
import { TokenManagementService } from '../../../token-management/token-management.service';
import { DateUtil } from '../../../utils';
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
import { AccessAttemptService } from './access-attempt.service';
import { Context } from './context.decorator';
import { LoginContextInterceptor } from './login-context.interceptor';
import { LoginStages } from './login.types';
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
import { Response } from 'express';
import { CacheService } from '../../../cache/cache.service';
import { Identity } from '../../../domain/identity.types';
const getStateKey = (jwi: string) => `login:state:${jwi}`;
const challengeToTemplate = {
[Identity.AuthDevice.Type.Password]: 'login-password-challenge',
}
@Controller({
version: '1',
path: 'auth/:realm/signin',
})
@UseInterceptors(LoginContextInterceptor)
export class LoginController {
constructor(
private readonly tokenService: TokenManagementService,
private readonly cacheService: CacheService,
private readonly accessAttemptService: AccessAttemptService,
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
) {}
@Get('identifier')
@Render('login-view')
async getLogin(
@Param('realm') realm: string,
@Query('error') error?: string,
): Promise<LoginStages.IdentifierView.RenderContext> {
const date = DateUtil.fromDate(new Date()).addNMinutes(15);
const stateObj: Authentication.Login.State = {
jwi: randomUUID(),
exp: date.seconds,
tfa: false,
}
await this.cacheService.set(getStateKey(stateObj.jwi), new Date().toISOString(), date.toDate().getTime());
return {
realm,
state: this.tokenService.createToken(stateObj),
user_settings: {
theme: 'dark',
},
links: {
identifier_form: `/auth/${realm}/signin/identifier`
}
}
}
@Post('identifier')
async postLogin(
@Param('realm') realm: string,
@Context() context: Authentication.Login.RequestContext,
@Res() res: Response,
) {
if (!context.isComplete) {
throw new BadRequestException();
}
if (context.isLocked) {
throw new TooManyRequestsException();
}
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
const selectedDevice = devicesForUser.length === 1 ? devicesForUser[0] : devicesForUser.find(d => d.preferred);
if (selectedDevice) {
const renderContext: LoginStages.ChallengeView.RenderContext = {
realm,
state: this.tokenService.createToken(context.state),
username: context.username,
user_settings: {
theme: 'dark',
},
links: {
select_device: devicesForUser.length > 1 ? `/auth/${realm}/signin/challenge` : null,
challenge_form: `/auth/${realm}/signin/challenge/${selectedDevice.urn}`,
}
}
return res.render(challengeToTemplate[selectedDevice.deviceType], renderContext);
}
}
@Get('challenge')
async selectDevice() {
}
@Post('challenge/:device_urn')
async postDevice(
@Param('realm') realm: string,
@Context() context: Authentication.Login.RequestContext,
@Res() res: Response,
) {
}
@Get('2fa/challenge')
async select2faDevice() {
}
@Post('2fa/challenge/:device_urn')
async post2faDevice() {
}
@Post('logout')
async logout() {
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { CacheModule } from '../../../cache/cache.module';
import { PersistenceModule } from '../../../persistence/persistence.module';
import { LoginController } from './login.controller';
import { AccessAttemptService } from './access-attempt.service';
import { LoginContextInterceptor } from './login-context.interceptor';
import { ConfigModule } from '../../../config/config.module';
import { TokenManagementModule } from '../../../token-management/token-management.module';
@Module({
imports: [
CacheModule,
PersistenceModule,
ConfigModule,
TokenManagementModule,
],
controllers: [
LoginController,
],
providers: [
AccessAttemptService,
LoginContextInterceptor,
],
})
export class LoginModule {}

View File

@@ -0,0 +1,70 @@
import { Identity } from '../../../domain/identity.types';
import { Authentication } from '../../../domain/authentication.types';
import { GlobalProps } from '../mvc.types';
export namespace LoginStages {
export enum Error {
INVALID,
}
export const errorDescriptionMap: Record<Error, string> = {
[Error.INVALID]: 'Invalid username'
}
export namespace IdentifierView {
export interface RenderContext extends GlobalProps {
state: string;
realm: string;
links: {
identifier_form: string;
}
}
}
export namespace ChallengeView {
export interface RenderContext extends GlobalProps {
state: string;
realm: string;
username: string;
links: {
select_device: string | null;
challenge_form: string;
}
}
}
export namespace $7PostLoginContext_Result {
export type Context = Success | TwoFactorRequired | Failure | Timedout | DeviceLocked;
type Success = {
status: Authentication.Login.ResponseStatus.Success;
state: string;
redirectUri: string;
}
type TwoFactorRequired = {
status: Authentication.Login.ResponseStatus.TwoFactorRequired;
state: string;
availableDevices: Identity.AuthDevice.Type[];
}
type Failure = {
status: Authentication.Login.ResponseStatus.Failure;
state: string;
}
type Timedout = {
status: Authentication.Login.ResponseStatus.Timedout;
}
type DeviceLocked = {
status: Authentication.Login.ResponseStatus.DeviceLocked;
}
}
}
export const supportedDevices = [
Identity.AuthDevice.Type.Password,
]

View File

@@ -0,0 +1,5 @@
export interface GlobalProps {
user_settings: {
theme: 'light' | 'dark';
}
}

View File

@@ -0,0 +1,48 @@
import { Controller, Get, Post } from '@nestjs/common';
@Controller({
version: '1',
path: 'auth/:realm/oauth2',
})
export class OauthController {
@Post('authorize')
async authorization() {
}
@Get('return')
async redirectReturn() {
}
@Post('token')
async token() {
}
@Get('user_info')
async userInfo() {
}
@Get('jwks_uri')
async jwksUri() {
}
@Get('introspection')
async introspection() {
}
@Get('revocation_endpoint')
async revokeBasic() {
}
@Post('revocation_endpoint')
async revoke() {
}
}

View File

@@ -0,0 +1,70 @@
import { Authentication } from '../../../domain/authentication.types';
interface ErrorResponse {
state?: string;
error: Authentication.Oauth2.Error;
error_description?: string;
error_uri?: string;
}
namespace AuthorizationCode {
interface AuthorizationRequest {
response_type: Authentication.Oauth2.ResponseType.Code;
client_id: string;
redirect_uri?: string;
scope?: string;
state?: string;
}
interface AuthorizationResponse {
code: string; // 10min redis
state?: string;
}
interface AccessTokenRequest {
grant_type: Authentication.Oauth2.AuthorizationGrant.AuthorizationCode;
code: string;
redirect_uri?: string;
client_id: string;
}
interface AccessTokenResponse {
access_token: string;
token_type: 'bearer';
expires_in: number;
refresh_token?: string;
}
}
// application/x-www-form-urlencoded
// Authorization header required if of type `confidential`
// Basic base64(clientId:clientSecret)
namespace ResourceOwner {
interface AccessTokenRequest {
grant_type: Authentication.Oauth2.GrantType.Password;
username: string;
password: string;
scope?: string;
}
interface AccessTokenResponse {
access_token: string;
token_type: 'bearer'; // ?
expires_in: number;
refresh_token?: string;
}
}
// `confidential` only
namespace ClientCredentials {
interface AccessTokenRequest {
// grant_type: Authentication.Oauth2.GrantType.ClientCredentials;
scope?: string;
}
interface AccessTokenResponse {
access_token: string;
token_type: 'bearer';
expires_in: number;
}
}

View File

@@ -0,0 +1,28 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import { ConfigService } from './config/config.service';
import { SystemSettings } from './domain/system-settings.types';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService: ConfigService = app.get(ConfigService);
const logger: Logger = new Logger();
app.useLogger(logger);
app.set('view engine', 'pug');
app.setBaseViewsDir(join(__dirname, '../../views'));
if (configService.get(SystemSettings.Auth.AccessAttempts.CheckForwardedFor)) {
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
}
await app.listen(3000, () => {
logger.log('Listening on port 3000', 'main.ts');
});
}
bootstrap();

View File

@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { Authentication } from '../domain/authentication.types';
@Injectable()
export class AuthAccessAttemptDao {
constructor(
private readonly prismaService: PrismaService,
) {}
async createAttempt(data: Authentication.AccessAttempt.CreateDto): Promise<void> {
await this.prismaService.authAccessAttempt.create({
data,
});
}
}

View File

@@ -0,0 +1,81 @@
import * as crypto from 'node:crypto';
import { SecureStringUtil } from '../../utils';
import { PrismaService } from '../../persistence/prisma.service';
import { DataMigration } from './data-migration.interface';
import { Identity } from '../../domain/identity.types';
import { SystemSettings } from '../../domain/system-settings.types';
export class _00ApplicationBootstrapDataMigration implements DataMigration {
readonly name = '00-application-bootstrap-data-migration';
async run(prisma: PrismaService) {
await prisma.$queryRaw`PRAGMA journal_mode=WAL;`;
await prisma.$transaction(async (tx) => {
const realm = await tx.authRealm.create({
data: {
name: 'main',
}
});
const adminGroup = await tx.identityGroup.create({
data: {
role: Identity.Group.Role.RealmAdmin,
realmId: realm.id,
}
});
const adminUser = await tx.identityUser.create({
data: {
username: 'admin',
realmId: realm.id,
}
});
await tx.identityGroupToIdentityUser.create({
data: {
userId: adminUser.id,
groupId: adminGroup.id,
},
});
const passwordDevice: Identity.AuthDevice.PasswordAttributes = {
expiry: new Date(0),
passwordHashString: await SecureStringUtil.generateNewHash('ThisIsExpired123!'),
locked: false,
}
await tx.identityAuthDevice.create({
data: {
userId: adminUser.id,
deviceType: Identity.AuthDevice.Type.Password,
attributes: JSON.stringify(passwordDevice),
preferred: true,
}
});
const startingConfig: SystemSettings.Config = {
[SystemSettings.Auth.AccessAttempts.CheckForwardedFor]: true,
[SystemSettings.Auth.AccessAttempts.MaxAttempts]: 5,
[SystemSettings.Auth.AccessAttempts.Timeout]: 15 * 60 * 1000,
[SystemSettings.Auth.Oauth2.Enabled]: true,
[SystemSettings.Auth.Oauth2.EncryptionSecret]: crypto.randomBytes(16).toString('hex'),
[SystemSettings.Auth.TokenManagement.SigningSecret]: crypto.randomBytes(16).toString('hex'),
[SystemSettings.Graphql.Debug]: false,
[SystemSettings.Graphql.IntrospectionEnabled]: true,
[SystemSettings.Graphql.PlaygroundEnabled]: true,
}
const paired = Object.entries(startingConfig)
.map(([hashKey, hashValue]) => ({ hashKey, hashValue: hashValue.toString(), hashValueType: typeof hashValue }));
await tx.systemSetting.createMany({
data: paired,
});
});
}
}

View File

@@ -0,0 +1,6 @@
import { PrismaService } from '../../persistence/prisma.service';
export interface DataMigration {
name: string;
run(prisma: PrismaService): void;
}

View File

@@ -0,0 +1 @@
export * from './00-application-bootstrap-data-migration';

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { IdentityAuthDevice } from '@prisma/client';
import { PrismaService } from './prisma.service';
import { Identity } from '../domain/identity.types';
import { safeJsonParse } from '../utils';
@Injectable()
export class IdentityAuthDeviceDao {
constructor(
private readonly prismaService: PrismaService,
) {}
async findByRealmAndUsername(realm: string, username: string): Promise<Identity.AuthDevice[]> {
const devices = await this.prismaService.identityAuthDevice.findMany({
where: {
user: {
username: username.toLowerCase(),
realm: {
name: realm.toLowerCase(),
},
},
},
});
return devices.map(d => IdentityAuthDeviceDao.modelToEntity(d, true));
}
async findOneByUrn(urn: string): Promise<Identity.AuthDevice> {
const device = await this.prismaService.identityAuthDevice.findFirst({
where: {
id: urn.replace('urn:identity:auth-device:', ''),
},
});
return IdentityAuthDeviceDao.modelToEntity(device);
}
private static modelToEntity(model: IdentityAuthDevice, skipAttributes = false): Identity.AuthDevice {
const [_, attributes] = safeJsonParse(model.attributes);
const entity: Identity.AuthDevice = {
urn: `urn:identity:auth-device:${model.id}`,
userId: model.userId,
deviceType: model.deviceType as Identity.AuthDevice.Type,
preferred: model.preferred,
createdAt: new Date(model.createdAt),
}
if (skipAttributes) {
return entity;
}
if (model.deviceType === Identity.AuthDevice.Type.Password) {
const passwordEntity: Identity.AuthDevice.PasswordAttributes = {
expiry: new Date(attributes[Identity.AuthDevice.PasswordHashKey.Expiry] as string),
passwordHashString: attributes[Identity.AuthDevice.PasswordHashKey.PasswordHashString] as string,
locked: attributes[Identity.AuthDevice.PasswordHashKey.Locked] as boolean,
}
return {
...entity,
...passwordEntity,
}
}
return entity;
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { AuthAccessAttemptDao } from './auth-access-attempt.dao';
import { IdentityAuthDeviceDao } from './identity-auth-device.dao';
import { SystemSettingsDao } from './system-settings.dao';
@Module({
providers: [
PrismaService,
AuthAccessAttemptDao,
IdentityAuthDeviceDao,
SystemSettingsDao,
],
exports: [
AuthAccessAttemptDao,
IdentityAuthDeviceDao,
SystemSettingsDao,
],
})
export class PersistenceModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { SystemSettings } from '../domain/system-settings.types';
type Result = {
typeMap: Record<string, 'string' | 'boolean' | 'number'>;
valueMap: Record<string, string>;
}
@Injectable()
export class SystemSettingsDao {
constructor(
private readonly prismaService: PrismaService,
) {}
async getSettings(): Promise<Result> {
const settings = await this.prismaService.systemSetting.findMany();
const typeMap = {}
const valueMap = {}
for (const { hashKey, hashValue, hashValueType } of settings) {
typeMap[hashKey] = hashValueType;
valueMap[hashKey] = hashValue;
}
return {
valueMap,
typeMap,
}
}
async hashKeyExists(hashKey: string): Promise<boolean> {
return await this.prismaService.systemSetting.count({
where: {
hashKey
}
}) > 0;
}
async setSetting(hashKey: keyof SystemSettings.Config, hashValue: string | boolean | number): Promise<void> {
await this.prismaService.systemSetting.update({
where: { hashKey },
data: {
hashValue: hashValue.toString(),
hashValueType: typeof hashValue
},
});
}
async setSettingWithType(hashKey: string, hashValueType: 'string' | 'boolean' | 'number', hashValue: string): Promise<void> {
await this.prismaService.systemSetting.update({
where: { hashKey },
data: {
hashValue: hashValue,
hashValueType: hashValueType,
},
});
}
}

View File

@@ -0,0 +1,53 @@
import { NestFactory, repl } from '@nestjs/core';
import { PersistenceModule } from './persistence/persistence.module';
import { PrismaService } from './persistence/prisma.service';
import { DataMigration } from './persistence/data-migrations/data-migration.interface';
import { _00ApplicationBootstrapDataMigration } from './persistence/data-migrations';
import { CloudDav } from './domain/cloud-dav.types';
import { Identity } from './domain/identity.types';
async function bootstrap() {
const app = await NestFactory.create(PersistenceModule);
const prismaService = app.get(PrismaService);
await ensureEnumIntegrity(prismaService);
const registeredMigrations: DataMigration[] = [
new _00ApplicationBootstrapDataMigration,
];
const alreadyRan = await prismaService.systemPostMigration.findMany();
const alreadyRanList = alreadyRan.map(e => e.name);
for (const migration of registeredMigrations) {
if (alreadyRanList.includes(migration.name)) {
continue;
}
await migration.run(prismaService);
await prismaService.systemPostMigration.create({ data: { name: migration.name }});
alreadyRanList.push(migration.name);
}
}
async function ensureEnumIntegrity(prisma: PrismaService) {
const enumsRegistered: [string, string[]][] = [
['enumIdentityGroupRole', Object.values<string>(Identity.Group.Role)],
['enumIdentityAuthDeviceType', Object.values<string>(Identity.AuthDevice.Type)],
['enumCloudDavResourceType', Object.values<string>(CloudDav.Resource.Type)],
];
for (const [dbRunner, known] of enumsRegistered) {
await prisma.$transaction(async (tx) => {
const result = await tx[dbRunner].findMany();
const existing = result.map(e => e.enumValue);
const missing = known.filter(k => !existing.includes(k));
await tx[dbRunner].createMany({
data: missing.map(enumValue => ({ enumValue })),
});
});
}
}
bootstrap();

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module';
import { TokenManagementService } from './token-management.service';
import { PersistenceModule } from '../persistence/persistence.module';
import { ConfigModule } from '../config/config.module';
@Module({
imports: [
CacheModule,
ConfigModule,
PersistenceModule,
],
providers: [TokenManagementService],
exports: [TokenManagementService],
})
export class TokenManagementModule {}

View File

@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import * as Joi from 'joi';
import { ConfigService } from '../config/config.service';
import { SystemSettings } from '../domain/system-settings.types';
import { JWA, TokenSignatureUtil, safeJsonParse } from '../utils';
import { CacheService } from '../cache/cache.service';
type Receipt = {
isToken: true;
signatureValid: boolean;
payload: Object;
revoked: boolean;
} | { isToken: false };
type Header = {
typ: 'JWT';
alg: JWA.HSA256;
}
@Injectable()
export class TokenManagementService {
constructor(
private readonly configService: ConfigService,
private readonly cacheService: CacheService,
) {}
createToken(payload: Object): string {
const header: Header = {
alg: JWA.HSA256,
typ: 'JWT',
}
const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString('base64url');
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
const signature = TokenSignatureUtil.generateSignature(`${encodedHeader}.${encodedPayload}`, header.alg, this.configService.get(SystemSettings.Auth.TokenManagement.SigningSecret));
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
parseToken(token: string): Receipt {
const parts = token.split('.');
if (parts.length !== 3) {
return { isToken: false }
}
const [encodedHeader, encodedPayload, signature] = parts;
const headerJson = Buffer.from(encodedHeader, 'base64url').toString('utf-8');
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
const [err1, headerObj] = safeJsonParse(headerJson);
const [err2, payloadObj] = safeJsonParse(payloadJson);
if (err1 || err2) {
return { isToken: false }
}
const { error: err3, value: header } = Joi.object<Header, true>({
alg: Joi.string().required().valid(JWA.HSA256),
typ: Joi.string().required().allow('JWT'),
}).validate(headerObj, { allowUnknown: false });
if (err3) {
return { isToken: false }
}
const regeneratedSignature = TokenSignatureUtil.generateSignature(`${encodedHeader}.${encodedPayload}`, header.alg, this.configService.get(SystemSettings.Auth.TokenManagement.SigningSecret));
const signatureValid = regeneratedSignature === signature;
return {
isToken: true,
signatureValid,
payload: payloadObj,
revoked: false,
}
}
}

View File

@@ -0,0 +1,91 @@
export class DateUtil {
private readonly original: Date | null;
constructor(
private _date: Date | null,
) {
this.original = DateUtil.newDate(_date);
}
static fromIso(iso: string) {
return new DateUtil(DateUtil.newDate(iso));
}
static fromDate(date: Date) {
return new DateUtil(date);
}
static fromSecondsSinceEpoch(seconds: number) {
return new DateUtil(new Date(seconds * 1000));
}
get seconds() {
return Math.floor(this._date.getTime() / 1000);
}
isInThePast(onNull = false): boolean {
if (!this._date) {
return onNull;
}
return this._date < new Date();
}
isInTheFuture(onNull = false): boolean {
if (!this._date) {
return onNull;
}
return this._date > new Date();
}
removeNDays(n: number): DateUtil {
if (!this._date) {
return this;
}
this._date.setDate(this._date.getDate() - n);
return this;
}
addNDays(n: number): DateUtil {
if (!this._date) {
return this;
}
this._date.setDate(this._date.getDate() + n);
return this;
}
addNSeconds(n: number): DateUtil {
if (!this._date) {
return this;
}
this._date = new Date(this._date.getTime() + n * 1000);
return this;
}
addNMinutes(n: number): DateUtil {
return this.addNSeconds(n * 60);
}
reset(): DateUtil {
this._date = DateUtil.newDate(this.original);
return this;
}
toDate(): Date {
return DateUtil.newDate(this._date);
}
private static newDate(date: string | Date | null): Date | null {
return date ? new Date(date) : null;
}
}

View File

@@ -0,0 +1,5 @@
export * from './date-util';
export * from './safe-json-parse';
export * from './secure-string';
export * from './snake-to-camel';
export * from './when-clause';

View File

@@ -0,0 +1,7 @@
export const safeJsonParse = (json: string): [Error, null] | [null, Object] => {
try {
return [null, JSON.parse(json)];
} catch (err) {
return [err, null];
}
}

View File

@@ -0,0 +1,136 @@
import * as bcrypt from 'bcrypt';
import * as crypto from 'node:crypto';
// https://passlib.readthedocs.io/en/stable/modular_crypt_format.html#mcf-identifiers
type OSDefinedHashPrefix = |
'$1$' |
'$2$' | '$2a$' | '$2x$' | '$2y$' | '$2b$' |
'$5$' |
'$6$'
;
enum NodeCryptoAlgs {
md5 = 'md5',
bcrypt = 'bcrypt',
sha256 = 'sha256',
sha512 = 'sha512',
}
const hashDictionary: Record<OSDefinedHashPrefix, NodeCryptoAlgs> = {
$1$: NodeCryptoAlgs.md5,
$2$: NodeCryptoAlgs.bcrypt,
$2a$: NodeCryptoAlgs.bcrypt,
$2x$: NodeCryptoAlgs.bcrypt,
$2y$: NodeCryptoAlgs.bcrypt,
$2b$: NodeCryptoAlgs.bcrypt,
$5$: NodeCryptoAlgs.sha256,
$6$: NodeCryptoAlgs.sha512,
}
const inversedHashDictionary: Record<NodeCryptoAlgs, OSDefinedHashPrefix> = {
[NodeCryptoAlgs.md5]: '$1$',
[NodeCryptoAlgs.bcrypt]: '$2$',
[NodeCryptoAlgs.sha256]: '$5$',
[NodeCryptoAlgs.sha512]: '$6$',
}
const saltStringLengthDictionary: Record<NodeCryptoAlgs, number> = {
[NodeCryptoAlgs.md5]: 23,
[NodeCryptoAlgs.bcrypt]: 23,
[NodeCryptoAlgs.sha256]: 23,
[NodeCryptoAlgs.sha512]: 23,
}
type HashingFunction = (rawString: string, workFactor: number, salt: string) => Promise<string>;
const algImplementationMap: Record<NodeCryptoAlgs, HashingFunction> = {
[NodeCryptoAlgs.md5]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
},
[NodeCryptoAlgs.bcrypt]: async function (rawString: string, _workFactor: number, salt: string): Promise<string> {
return bcrypt.hash(rawString, salt);
},
[NodeCryptoAlgs.sha256]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
},
[NodeCryptoAlgs.sha512]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
}
}
export enum JWA {
HSA256 = 'HSA256',
}
type SigningFunction = (rawString: string, signingComponent: string) => string;
const jwaImplementationMap: Record<JWA, SigningFunction> = {
[JWA.HSA256]: function (rawString: string, signingComponent: string) {
return crypto.createHmac('sha-256', signingComponent).update(rawString).digest('base64url');
},
}
export class SecureStringUtil {
private static readonly defaultWorkFactor = 15;
static async generateNewHash(rawString: string): Promise<string> {
const alg = NodeCryptoAlgs.bcrypt;
const workFactor = this.defaultWorkFactor;
const salt = await bcrypt.genSalt(workFactor);
return algImplementationMap[alg](rawString, workFactor, salt);
}
static async generateHash(rawString: string, alg: NodeCryptoAlgs, workFactor: number, salt: string): Promise<string> {
return algImplementationMap[alg](rawString, workFactor, salt);
}
static async compare(rawString: string, hashMetaSerialized: string): Promise<boolean> {
const { alg, salt, workFactor, hash: hashA} = HashMeta.deserialize(hashMetaSerialized);
const hashB = await this.generateHash(rawString, alg, workFactor, salt);
return hashA === hashB;
}
}
export class TokenSignatureUtil {
static generateSignature(rawString: string, alg: JWA, signingComponent: string): string {
return jwaImplementationMap[alg](rawString, signingComponent);
}
}
class HashMeta {
constructor(
public readonly alg: NodeCryptoAlgs,
public readonly workFactor: number,
public readonly salt: string,
public readonly hash: string,
) {}
static deserialize(hashMeta: string): HashMeta {
const [_, alg, workFactor, salthash] = hashMeta.split('$');
const saltStringLength = saltStringLengthDictionary[alg];
const salt = salthash.substring(0, saltStringLength);
const hash = salthash.substring(saltStringLength);
return new HashMeta(
hashDictionary[alg],
+workFactor,
salt,
hash
);
}
serialize(): string {
return [
'',
inversedHashDictionary[this.alg],
this.workFactor,
`${this.salt}${this.hash}`
].join('$');
}
}

View File

@@ -0,0 +1,8 @@
// https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase
export const snakeToCamel = str =>
str.toLowerCase().replace(/([-_][a-z])/g, group =>
group
.toUpperCase()
.replace('-', '')
.replace('_', '')
);

View File

@@ -0,0 +1,45 @@
type Literal = string | number;
type Resolved<T> = T | (() => T);
type Then<T, K extends Literal> = (result: Resolved<T>) => WhenClosure<T, K>;
const defaultErrorMessage = 'Runtime exception: failed to handle all scenarios of when clause';
class WhenClosure<T, K extends Literal> {
private readonly stack: {[key: Literal]: Resolved<T>} = {};
constructor(private readonly expression: Literal) {}
matches(literal: K): { then: Then<T, K> } {
return {
then: (result: Resolved<T>) => {
this.stack[literal] = result;
return this;
}
}
}
else(result: Resolved<T>): T {
const resolved = this.expression in this.stack ? this.stack[this.expression] : result;
if (resolved instanceof Function) {
return resolved();
}
return resolved;
}
resolveOrThrow(errorMessage = defaultErrorMessage): T {
if (this.expression in this.stack) {
const resolved = this.stack[this.expression];
if (resolved instanceof Function) {
return resolved();
}
return resolved;
}
throw new Error(errorMessage);
}
}
export const declare = <T, K extends Literal = Literal>() => ({
when: (expression: K) => new WhenClosure<T, K>(expression)
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
},
"exclude": [
"codegen.ts"
]
}

View File

@@ -0,0 +1,7 @@
doctype html
html(data-theme=user_settings.theme)
head
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css")
script(src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous")
body
block content

View File

@@ -0,0 +1,9 @@
div#innerTarget
form(hx-post=links.challenge_form hx-select="#innerTarget" hx-swap="outerHTML")
fieldset
input(type="hidden" name="state" value=state)
input(type="text" name="username" placeholder="Username" value=username readonly)
input(type="password" name="password" placeholder="Password")
input(type="submit" name="submit" value="Submit")
if links.select_device
a(hx-get=links.select_device hx-select="#innerTarget" hx-swap="outerHTML" href='#') Use a different device

View File

@@ -0,0 +1,9 @@
extends base.pug
block content
main
form(hx-post=links.identifier_form hx-swap="outerHTML")
fieldset
input(type="hidden" name="state" value=state)
input(type="text" name="username" placeholder="Username")
input(type="submit" name="submit" value="Submit")